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);
}
}
}
}