Discord Botを使ってローカルLLMをしゃべらせる(VoiceVox編)

Discordのボイスチャットに参加している状態で、テキストチャットをすると返事をテキストと音声で返すc#プログラムです。ユーザーが音声でしゃべっても返事をしませんのでご注意ください。どうしてもしゃべりたい場合は、PCの場合はChrome + Voice InをiPhoneの場合は音声コントロールを使ってテキスト入力してください。

Nuget情報


概要
Discordにログインして音声チャットを開始するとBotが参加します。
(Botの設定とトークンは事前に登録しておいてください)
その状態でテキストチャットを行うと、テキストで返事を返すのと同時に音声でしゃべります。音声はVoiceVoxを使いますのでアプリを起動しておいてください。

Program.cs

using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.VoiceNext;
using LLama.Common;
using LLama;
using DSharpPlus.EventArgs;
using System.Diagnostics;
using System.Net.Http.Headers;

namespace DSharpPlus_Bots
{
    public sealed class Program
    {
        private static bool Op = true;
        private static VoiceNextConnection? VoiceCon;
        public static async Task Main()
        {
            try
            {
                ChatSession chtSess;
                InferenceParams infPara;
                // LLMモデルの場所
                string strModelPath = Environment.GetEnvironmentVariable("LLMPATH", System.EnvironmentVariableTarget.User) + @"mradermacher\Shadows-MoE-GGUF\Shadows-MoE.Q8_0.gguf"; //◎
                //チャットログ
                string strChatlogPath = Environment.GetEnvironmentVariable("CHATDB", System.EnvironmentVariableTarget.User) + @"ChatDB.db";
                ChatHistoryDB chtDB;

                //LLMモデルのロードとパラメータの設定
                Console.ForegroundColor = ConsoleColor.Blue;
                ModelParams modPara = new(strModelPath)
                {
                    ContextSize = 4096,
                    Seed = 1337,
                    GpuLayerCount = 24,
                };

                using LLamaWeights llmWeit = LLamaWeights.LoadFromFile(modPara);
                using LLamaContext llmContx = llmWeit.CreateContext(modPara);
                InteractiveExecutor itrEx = new(llmContx);

                //チャットログを読み込みます。
                ChatHistory chtHis = new ChatHistory();
                chtDB = new ChatHistoryDB(strChatlogPath, chtHis);

                chtSess = new(itrEx, chtHis);
                var varHidewd = new LLamaTransforms.KeywordTextOutputStreamTransform(["User: ", "Assistant: "]);
                chtSess.WithOutputTransform(varHidewd);
                infPara = new()
                {
                    Temperature = 0.8f,
                    AntiPrompts = ["User:"],
                    //AntiPrompts = ["User:", "<|eot_id|>"],  //Llama3用
                    MaxTokens = 256,
                };

                //ここからDiscordの処理
                // ユーザー環境変数に登録した「DISCORD_TOKEN」を持ってくる
                string? token = Environment.GetEnvironmentVariable("DISCORD_TOKEN", System.EnvironmentVariableTarget.User);
                if (string.IsNullOrWhiteSpace(token))
                {
                    Console.WriteLine("Please specify a token in the DISCORD_TOKEN environment variable.");
                    Environment.Exit(1);

                    return;
                }

                DiscordConfiguration config = new()
                {
                    Token = token,
                    TokenType = TokenType.Bot,
                    Intents = DiscordIntents.AllUnprivileged | DiscordIntents.MessageContents | DiscordIntents.GuildVoiceStates
                };

                DiscordClient client = new(config);
                client.UseVoiceNext();

                //メッセージイベント
                client.MessageCreated += async (client, eventArgs) =>
                {
                    //ボットに応答しない
                    if (eventArgs.Message.Author.Id==client.CurrentUser.Id)
                        return;

                    // ユーザーのターン
                    ChatHistory.Message msg = new(AuthorRole.User, "User: " + eventArgs.Message.Content);
                    if (Op) chtDB.WriteHistory(AuthorRole.User, "User: " +  eventArgs.Message.Content);

                    // AIのターン
                    string strMsg = "";
                    await foreach (string strAns in chtSess.ChatAsync(msg, infPara))
                    {
                        strMsg += strAns;
                    }
                    //Discordに発信するときは「User:」や「Assistant:」を抜く
                    string strSndmsg = strMsg.Replace("User:", "").Replace("Assistant:", "").Replace("assistant:", "").Trim();
                    await eventArgs.Message.RespondAsync(strSndmsg);
                    if (Op) chtDB.WriteHistory(AuthorRole.Assistant, strMsg.Replace("User:", "").Trim()+"\n"+"User:"); //AntiPromptsの位置を再調整

                    if (VoiceCon is not null) //Voiceチャンネルが開いているときは音声を送信
                    {
                        ValueTask<MemoryStream> msRes = Voicevox(strSndmsg);
                        msRes.AsTask().Wait();
                        await SendAsync(VoiceCon, msRes.Result);
                    }
                };

                client.Ready += OnClientReady;
                client.GuildAvailable += OnGuildAvailable;
                client.VoiceStateUpdated += OnVoiceStateUpdated;

                DiscordActivity status = new("with fire", ActivityType.Playing);

                await client.ConnectAsync(status, UserStatus.Online);

                await Task.Delay(-1);
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString());
            }
        }

        private static Task OnClientReady(DiscordClient client, ReadyEventArgs e)
        {
            Console.WriteLine("Bot is ready!");
            return Task.CompletedTask;
        }

        private static Task OnGuildAvailable(DiscordClient client, GuildCreateEventArgs e)
        {
            Console.WriteLine($"Guild available: {e.Guild.Name}");
            return Task.CompletedTask;
        }

        private static async Task OnVoiceStateUpdated(DiscordClient client, VoiceStateUpdateEventArgs e)
        {
            try
            {
                //Bot以外のチャンネルが開いた場合
                if (e.After.Channel != null && !e.After.User.IsBot)
                {
                    Console.WriteLine($"{e.After.User.Username} joined voice channel {e.After.Channel.Name}");

                    var vnext = client.GetVoiceNext();
                    var connection = await vnext.ConnectAsync(e.After.Channel);

                    VoiceCon = connection;

                }
                //Botが退出した場合はコネクションをNULLにする
                if (e.After.Channel == null && e.After.User.IsBot)
                {
                    VoiceCon = null;
                }
            }
            catch (Exception ex) 
            { 
                Console.WriteLine(ex.ToString());
            }
        }

        private static async Task SendAsync(VoiceNextConnection connection, MemoryStream ms)
        {
            try
            {
                var transmit = connection.GetTransmitSink();
                var pcm = ConvertAudioToPcm(ms);
                await pcm.CopyToAsync(transmit);
                await pcm.DisposeAsync();
                ms.Close();
                ms.Dispose();
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString());
            }
        }

        private static Stream ConvertAudioToPcm(MemoryStream ms)
        {
            try
            {
                var ffmpeg = Process.Start(new ProcessStartInfo
                {
                    FileName = "ffmpeg",
                    Arguments = $@"-i pipe:0 -ac 2 -f s16le -ar 48000 pipe:1",
                    RedirectStandardInput = true,
                    RedirectStandardOutput = true,
                    UseShellExecute = false
                });

                ffmpeg.StandardInput.BaseStream.WriteAsync(ms.ToArray(), 0, (int)ms.Length);
                ffmpeg.StandardInput.BaseStream.FlushAsync();
                return ffmpeg.StandardOutput.BaseStream;
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString());
                return null;
            }
        }
        public static async ValueTask<MemoryStream> Voicevox(string strMsg)
        {
            try
            {
                using (var httpClient = new HttpClient())
                {
                    string strCnt;
                    int intSpeaker = 8; //春日部つむぎ

                    using (var htpReq = new HttpRequestMessage(new HttpMethod("POST"), $"http://localhost:50021/audio_query?text={strMsg}&speaker={intSpeaker}&speedScale=1.1&prePhonemeLength=0&postPhonemeLength=0&intonationScale=1.16&enable_interrogative_upspeak=true"))
                    {
                        htpReq.Headers.TryAddWithoutValidation("accept", "application/json");
                        htpReq.Content = new StringContent("");
                        htpReq.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded");

                        var rspMsg = await httpClient.SendAsync(htpReq);
                        strCnt = rspMsg.Content.ReadAsStringAsync().Result;
                    }

                    using (var htpReq = new HttpRequestMessage(new HttpMethod("POST"), $"http://localhost:50021/synthesis?speaker={intSpeaker}&enable_interrogative_upspeak=true&speedScale=1.1&prePhonemeLength=0&postPhonemeLength=0&intonationScale=1.16"))
                    {
                        htpReq.Headers.TryAddWithoutValidation("accept", "audio/wav");
                        htpReq.Content = new StringContent(strCnt);
                        htpReq.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json");

                        var htpRes = await httpClient.SendAsync(htpReq);

                        MemoryStream ms = new MemoryStream();
                        using (var htpStm = await htpRes.Content.ReadAsStreamAsync())
                        {
                            //メモリストリームにコピー
                            htpStm.CopyTo(ms);
                            ms.Flush();
                        }
                        ms = new MemoryStream(ms.ToArray());
                        return ms;
                    }

                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.ToString());
                return null;
            }

        }

    }
}




ChatHistoryDB.cs

using LLama.Common;
using System.Data.SQLite;

namespace DSharpPlus_Bots
{
    class ChatHistoryDB
    {
        ChatHistory? chtHis;
        string strDbpath;
        Dictionary<string, AuthorRole>? Roles = new Dictionary<string, AuthorRole> { { "System" ,AuthorRole.System}, { "User", AuthorRole.User }, { "Assistant", AuthorRole.Assistant } };

        public ChatHistoryDB(string strDbpath, ChatHistory chtHis){

            this.chtHis= chtHis;
            this.strDbpath= strDbpath;
            try
            {
                var conSb = new SQLiteConnectionStringBuilder { DataSource = strDbpath };

                var con = new SQLiteConnection(conSb.ToString());
                con.Open();

                using (var cmd = new SQLiteCommand(con))
                {
                    cmd.CommandText="CREATE TABLE IF NOT EXISTS ch(" +
                        "sq  INTEGER PRIMARY KEY," +
                        "dt  TEXT NOT NULL," +
                        "id  TEXT NOT NULL," +
                        "msg TEXT)";
                    cmd.ExecuteNonQuery();

                    cmd.CommandText="select * from ch order by sq";
                    using (var reader = cmd.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            if (chtHis is null) chtHis=new ChatHistory();
                            chtHis.AddMessage(Roles[(string)reader["id"]], (string)reader["msg"]);
                        }
                    }
                }

                con.Close();

            }
            catch (Exception ex) 
            {
                Console.WriteLine(ex.Message);
            }

        }

        public interface IDisposable
        {
            void Dispose();
        }


        public void WriteHistory(AuthorRole aurID, string strMsg,bool booHis=false) 
        {
            try
            {
                var conSb = new SQLiteConnectionStringBuilder { DataSource = strDbpath };

                var con = new SQLiteConnection(conSb.ToString());
                con.Open();

                using (var cmd = new SQLiteCommand(con))
                {
                    if (booHis)
                    {
                        if (chtHis is null) chtHis=new ChatHistory();
                        chtHis.AddMessage(aurID, strMsg);
                    }
                    cmd.CommandText = $"insert into ch(dt,id,msg) values(datetime('now', 'localtime'),'{Roles.FirstOrDefault(v => v.Value.Equals(aurID)).Key}','{strMsg}')";
                    cmd.ExecuteNonQuery();
                }
                con.Close();

            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
        }
    }
}

Follow me!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です