AIサーバー試作品1号
色々やってきましたが、ようやくAIサーバー試作品1号ができあがりました。まだバグがあるかもですが使いながらメンテナンスしていきます。
機能
Discord Botと同時に家では、音声で話しかけると音声で返してきます。
Discordは、基本は文字チャットですが音声チャットに接続すると文字を返してきながらしゃべります。
ここからコードです
Nuget情報
概要
SpeechRecognitionを使って音声イベントを拾いVoskで文字起ししてLLMにインプットする。DiscordでチャットするとDiscord Botとして機能し文字で返す。音声チャットがオンラインの時は、同時に音声でも返す。
LLMはVecteus2gemma-2-27b-itを使用
チャット履歴は、Sqliteで管理しておりプログラム起動時にオーバーフローしない程度のチャット履歴の要約を行う。要約処理は、LLMのContextSizeの1/2より多い場合に行う。チャット履歴の要約後は、論理削除を行っているだけなので削除フラグを外すと復元できる。その場合、Sqliteを直接メンテナンスするかSQLで行う。
音声はCevioAIを使用している。VoiceVoxを使用したい場合は、過去記事を参考にして自力で修正するかコメントで「VoiceVox希望」とリクエストしていただければ掲載するかもしれない。
App.configとApp.SettingでDiscordチェンネルIDやSqliteのテーブル名を管理している。(過去記事参照)
メインの部分
Profram.cs
2024/9/21変更: AIが話している時に話すとエラーになるのを回避、RepeatPenaltyをコメントアウト
2024/9/22変更:マイクとスピーカーがくっついたデバイスを使用するとAIが話した音声を聞いてしまうのを回避
using System.Diagnostics;
using System.Speech.Recognition;
using System.Text.Json;
using LLama;
using LLama.Common;
using LLama.Sampling;
using DSharpPlus;
using DSharpPlus.Entities;
using DSharpPlus.VoiceNext;
using DSharpPlus.EventArgs;
using Vosk;
namespace DiscordBots02
{
public sealed class Program
{
static void Main(string[] args)
{
//コンソールアプリケーションからAsyncを呼び出す
Task task = MainAsync();
//終了を待つ
task.Wait();
}
private static bool Op = false; //本番フラグ
private static VoiceNextConnection? VoiceCon; //Voiceチャットコネクション
public static async Task MainAsync()
{
try
{
//チャットログの場所
string strChatlogPath = Environment.GetEnvironmentVariable("CHATDB", System.EnvironmentVariableTarget.User) + @"ChatDB.db";
//コンテキストサイズ
uint intContextSize = 4096;
//処理中フラグ
bool Wt = false;
//チャットログ要約処理
Task task = HistorySummary.Run(strChatlogPath, intContextSize / 2, App.Default.ChatDB_Table);
task.Wait();
// LLMモデルの場所
string strModelPath = Environment.GetEnvironmentVariable("LLMPATH", System.EnvironmentVariableTarget.User) + @"dahara1\gemma-2-27b-it-gguf-japanese-imatrix\gemma-2-27b-it.f16.Q4_k_m.gguf";
//Vosk設定
Vosk.Vosk.SetLogLevel(-1); //LogメッセージOFF
Model model = new Model(Environment.GetEnvironmentVariable("LLMPATH", System.EnvironmentVariableTarget.User)+@"vosk\vosk-model-ja-0.22");
//チャットログ
ChatHistoryDB chtDB;
//LLMモデルのロードとパラメータの設定
Console.ForegroundColor = ConsoleColor.Blue;
ChatSession chtSess;
InferenceParams infPara;
ModelParams modPara = new(strModelPath)
{
ContextSize = intContextSize,
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, App.Default.ChatDB_Table);
chtSess = new(itrEx, chtHis);
var varHidewd = new LLamaTransforms.KeywordTextOutputStreamTransform(["User: ", "Assistant: "]);
chtSess.WithOutputTransform(varHidewd);
infPara = new()
{
SamplingPipeline = new DefaultSamplingPipeline()
{
Temperature = 0.9f,
//RepeatPenalty = 1.0f,
},
AntiPrompts = ["User:"],
MaxTokens = 256,
};
// SpeechRecognitionの設定
SpeechRecognitionEngine recognizer = new SpeechRecognitionEngine(new System.Globalization.CultureInfo("ja-JP"));
recognizer.LoadGrammar(new DictationGrammar());
// ▼▼▼ ここからSpeechRecognizedイベント定義 開始 ▼▼▼
recognizer.SpeechRecognized += async (sender, e) =>
{
if (!Wt)
{
Wt = true;
//RecognizedしたwaveをMemoryStreamに書き込み
MemoryStream st = new MemoryStream();
e.Result.Audio.WriteToWaveStream(st);
st.Position = 0;
// byte buffer
VoskRecognizer rec = new VoskRecognizer(model, 16000.0f);
rec.SetMaxAlternatives(0);
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = st.Read(buffer, 0, buffer.Length)) > 0)
rec.AcceptWaveform(buffer, bytesRead);
// Json形式のResultからテキストを抽出
string jsontext = rec.FinalResult();
string strUserInput = "";
var jsondoc = JsonDocument.Parse(jsontext);
if (jsondoc.RootElement.TryGetProperty("text", out var element))
{
strUserInput = element.GetString() ?? "";
}
strUserInput = strUserInput.Replace(" ", "");
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine($"User: {strUserInput}");
ChatHistory.Message msgText = new(AuthorRole.User, "User: "+ strUserInput);
if (Op) chtDB.WriteHistory(AuthorRole.User, "User: "+ strUserInput);
// 回答の表示
Console.ForegroundColor = ConsoleColor.Yellow;
string strMsg = "";
await foreach (string strText in chtSess.ChatAsync(msgText, infPara))
{
strMsg += strText;
}
//発信するときは「User:」や「Assistant:」を抜く
string strSndmsg = strMsg.Replace("User:", "").Replace("Assistant:", "").Replace("assistant:", "").Trim();
Console.WriteLine(strSndmsg);
//MemoryStream破棄
st.Close();
st.Dispose();
Wt = false;
}
};
// ▲▲▲ ここからSpeechRecognizedイベント定義 終了 ▲▲▲
// Configure input to the speech recognizer.
recognizer.SetInputToDefaultAudioDevice();
// Start asynchronous, continuous speech recognition.
recognizer.RecognizeAsync(RecognizeMode.Multiple);
//ここから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();
string wavFilePath = Environment.GetEnvironmentVariable("TESTDATA", System.EnvironmentVariableTarget.User) + "output.wav";
//メッセージイベント
client.MessageCreated += async (client, eventArgs) =>
{
//ボットに応答しない
if (eventArgs.Message.Author.Id==client.CurrentUser.Id)
return;
//ターゲットチャンネル
if (eventArgs.Channel.Id != App.Default.Channel_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:");
if (VoiceCon is not null) //Voiceチャンネルが開いているときは音声を送信
{
CevioAI_Wave(strSndmsg, wavFilePath);
await SendAsync(VoiceCon, wavFilePath);
}
};
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, string filePath)
{
var transmit = connection.GetTransmitSink();
var pcm = ConvertAudioToPcm(filePath);
await pcm.CopyToAsync(transmit);
await pcm.DisposeAsync();
}
private static Stream ConvertAudioToPcm(string filePath)
{
var ffmpeg = Process.Start(new ProcessStartInfo
{
FileName = "ffmpeg",
Arguments = $@"-i ""{filePath}"" -ac 2 -f s16le -ar 48000 pipe:1",
RedirectStandardOutput = true,
UseShellExecute = false
});
return ffmpeg.StandardOutput.BaseStream;
}
private static void CevioAI_Wave(string strMsg, string filePath)
{
try
{
dynamic service = Activator.CreateInstance(Type.GetTypeFromProgID("CeVIO.Talk.RemoteService2.ServiceControl2V40"));
service.StartHost(false);
dynamic talker = Activator.CreateInstance(Type.GetTypeFromProgID("CeVIO.Talk.RemoteService2.Talker2V40"));
talker.Cast = "双葉湊音";
dynamic result = talker.OutputWaveToFile(strMsg, filePath);
//開放忘れるとメモリリーク
System.Runtime.InteropServices.Marshal.ReleaseComObject(talker);
System.Runtime.InteropServices.Marshal.ReleaseComObject(service);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
private static void CevioAI_Play(string strMsg)
{
try
{
dynamic service = Activator.CreateInstance(Type.GetTypeFromProgID("CeVIO.Talk.RemoteService2.ServiceControl2V40"));
service.StartHost(false);
dynamic talker = Activator.CreateInstance(Type.GetTypeFromProgID("CeVIO.Talk.RemoteService2.Talker2V40"));
talker.Cast = "双葉湊音";
dynamic result = talker.Speak(strMsg);
result.Wait();
//開放忘れるとメモリリーク
System.Runtime.InteropServices.Marshal.ReleaseComObject(talker);
System.Runtime.InteropServices.Marshal.ReleaseComObject(service);
}
catch (Exception ex)
{
Console.WriteLine(ex.ToString());
}
}
}
}
Sqliteでチャット履歴を管理している部分
ChatHistoryDB.cs
2024/9/22変更:SqliteDBがない時にシステム行を挿入する処理を追加
using LLama.Common;
using System.Data.SQLite;
namespace DiscordBots02
{
class ChatHistoryDB
{
ChatHistory? chtHis;
string strDbpath;
Dictionary<string, AuthorRole>? Roles = new Dictionary<string, AuthorRole> { { "System" ,AuthorRole.System}, { "User", AuthorRole.User }, { "Assistant", AuthorRole.Assistant } };
string strTable;
public ChatHistoryDB(string strDbpath, ChatHistory chtHis, string strTable){
this.chtHis= chtHis;
this.strDbpath= strDbpath;
this.strTable= strTable;
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 {strTable}(" +
"\"sq\" INTEGER," +
"\"dt\" TEXT NOT NULL," +
"\"id\" TEXT NOT NULL," +
"\"msg\" TEXT," +
"\"flg\" INTEGER DEFAULT 0, PRIMARY KEY(\"sq\"))";
cmd.ExecuteNonQuery();
cmd.CommandText = $"select count(*) from {strTable}";
using (var reader = cmd.ExecuteReader())
{
//一行も存在しない場合はシステム行をセットアップ
long reccount = 0;
if(reader.Read()) reccount = (long)reader[0];
reader.Close();
if(reccount<1)
{
//Assistantの性格セットアップ行追加
cmd.CommandText = $"insert into {strTable}(dt,id,msg) values(datetime('now', 'localtime'),'System','あなたは優秀なアシスタントです。')";
cmd.ExecuteNonQuery();
//要約行追加
cmd.CommandText = $"insert into {strTable}(dt,id,msg) values(datetime('now', 'localtime'),'System','')";
cmd.ExecuteNonQuery();
}
}
cmd.CommandText = $"select * from {strTable} where flg=0 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 {strTable}(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);
}
}
}
}
チャット履歴を要約してオーバーフローを防いでいる部分
HistorySummary.cs
using LLama.Common;
using LLama;
using System.Data.SQLite;
namespace DiscordBots02
{
internal class HistorySummary
{
public static async Task Run(string strChatlogPath, uint HistoryMax, string strTable)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("**Start History Summary**");
try
{
// LLMモデルの場所
string strModelPath = Environment.GetEnvironmentVariable("LLMPATH", System.EnvironmentVariableTarget.User) + @"mmnga\DataPilot-ArrowPro-7B-RobinHood-gguf\DataPilot-ArrowPro-7B-RobinHood-Q8_0.gguf";
// ChatDBから行を読み込む
string strChtHis = "";
uint i = 0;
uint intCtxtSize = 4096; //一度に処理できる文字数
long svSq = 0; //処理を開始したSq
long svSqMax = 0; //処理を終了したSq
string strSav = ""; //処理前の要約
var conSb = new SQLiteConnectionStringBuilder { DataSource = strChatlogPath };
var con = new SQLiteConnection(conSb.ToString());
con.Open();
using (var cmd = new SQLiteCommand(con))
{
//有効文字数をカウント
cmd.CommandText = $"select sum(length(msg)) from {strTable} where flg=0";
using (var reader = cmd.ExecuteReader())
{
reader.Read();
//Console.WriteLine($"文字数:{reader[0]}");
//チャット履歴の最大行に満たないか、最大文字数を越えても処理できる文字数が充分でない場合は処理をしない
if ((long)reader[0] < (long)HistoryMax || (long)reader[0] - (long)HistoryMax < (long)intCtxtSize)
{
con.Close();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("**E.N.D History Summary**");
return;
}
}
//有効行を呼んで要約する
cmd.CommandText = $"select * from {strTable} where flg=0 order by sq";
string strCht = "";
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
if ((string)reader["id"] != "System")
{
strCht = (string)reader["id"] + ": " + ((string)reader["msg"]).Replace("User:", "").Replace("Assistant:", "").Replace("assistant:", "").Trim() + "\n";
i += (uint)strCht.Length;
if (i > intCtxtSize) { break; }
strChtHis += strCht;
}
//システム行の要約を退避する
if ((string)reader["id"] == "System" && (long)reader["sq"] == 2) strSav = (string)reader["msg"];
//システム行以外で処理を開始した行を記録
if ((string)reader["id"] != "System" && svSq == 0)
{
svSq = (long)reader["sq"];
}
svSqMax = (long)reader["sq"];
}
}
}
//LLMの処理
Console.ForegroundColor = ConsoleColor.Blue;
//LLMモデルのロードとパラメータの設定
ModelParams modPara = new(strModelPath)
{
ContextSize = intCtxtSize,
GpuLayerCount = 24
};
ChatHistory chtHis = new ChatHistory();
chtHis.AddMessage(AuthorRole.System, "#命令書\n" +
"・あなたは優秀な編集者です。\n" +
"・ユーザーとアシスタントの会話を要約してください。\n" +
"#条件\n" +
"・重要なキーワードを取りこぼさない。\n" +
"#出力形式\n" +
"・例)要約: ふたりは親密な会話しました。");
using LLamaWeights llmWeit = LLamaWeights.LoadFromFile(modPara);
using LLamaContext llmContx = llmWeit.CreateContext(modPara);
InteractiveExecutor itrEx = new(llmContx);
ChatSession chtSess = new(itrEx, chtHis);
var varHidewd = new LLamaTransforms.KeywordTextOutputStreamTransform(["User:", "Assistant:"]);
chtSess.WithOutputTransform(varHidewd);
InferenceParams infPara = new()
{
Temperature = 0f,
AntiPrompts = new List<string> { "User:" }
};
// ユーザーのターン
Console.ForegroundColor = ConsoleColor.White;
string strInput = strChtHis;
ChatHistory.Message msg = new(AuthorRole.User, strInput);
// AIのターン
Console.ForegroundColor = ConsoleColor.Magenta;
string strMsg = "";
await foreach (string strAns in chtSess.ChatAsync(msg, infPara))
{
Console.Write(strAns);
strMsg += strAns;
}
using (var cmd = new SQLiteCommand(con))
{
//sq=1に要約内容を更新する(要約という文字、改行、空白を削除)
string strSql = $"update {strTable} set msg= '{strSav +"\n"+ strMsg.Replace("要約:", "").Replace("\r", "").Replace("\n", "").Trim()}' where sq=2 and id='System'";
//Console.Write (strSql+"\n");
cmd.CommandText = strSql;
cmd.ExecuteNonQuery();
//処理した行を論理削除する
strSql = $"update {strTable} set flg=1 where (sq between {svSq} and {svSqMax}) and id<>'System' and flg=0";
//Console.Write(strSql+"\n");
cmd.CommandText = strSql;
cmd.ExecuteNonQuery();
}
//DBクローズ
con.Close();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("**E.N.D History Summary**");
}
}
}
実行結果
Discord Botですが、ローカルで話しかけるとちゃんと音声で答えます