diff --git a/SimpleVoiceroid2Proxy/HttpRequest.cs b/SimpleVoiceroid2Proxy/HttpRequest.cs deleted file mode 100644 index ebffd2f..0000000 --- a/SimpleVoiceroid2Proxy/HttpRequest.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.Specialized; -using System.IO; -using System.Net; -using System.Text; -using System.Threading.Tasks; -using System.Web; -using Newtonsoft.Json; - -namespace SimpleVoiceroid2Proxy -{ - internal class HttpRequest - { - private readonly HttpListenerRequest request; - private readonly HttpListenerResponse response; - private readonly StreamWriter writer; - - public HttpRequest(HttpListenerContext context) - { - request = context.Request; - response = context.Response; - writer = new StreamWriter(response.OutputStream); - } - - private NameValueCollection Query => HttpUtility.ParseQueryString(request.Url.Query, Encoding.UTF8); - - public async Task HandleAsync() - { - using (response) - { - response.AddHeader("Content-Type", "application/json"); - response.AddHeader("Access-Control-Allow-Origin", "*"); - response.AddHeader("Access-Control-Allow-Method", "GET"); - - using (writer) - { - try - { - switch (request.Url.AbsolutePath) - { - case "/talk": - await TalkAsync(); - return; - default: - await Respond(HttpStatusCode.NotFound, "Request path not found."); - return; - } - } - catch (Exception exception) - { - await Respond(HttpStatusCode.InternalServerError, "Internal error occurred."); - - Program.Logger.Error(exception); - } - } - } - } - - private async Task TalkAsync() - { - string? text = null; - switch (request.HttpMethod) - { - case "GET": - text = Query["text"]; - break; - case "POST": - { - using var reader = new StreamReader(request.InputStream, Encoding.UTF8); - var content = await reader.ReadToEndAsync(); - dynamic? json = JsonConvert.DeserializeObject(content); - - text = json?.text; - break; - } - case "OPTIONS": - { - response.AddHeader("Access-Control-Allow-Method", "GET, POST, OPTIONS"); - response.AddHeader("Access-Control-Allow-Headers", "Content-Type"); - response.AddHeader("Access-Control-Max-Age", "7200"); - response.StatusCode = (int) HttpStatusCode.NoContent; - return; - } - } - - if (string.IsNullOrWhiteSpace(text)) - { - await Respond(HttpStatusCode.BadRequest, "`text` parameter is null or empty."); - return; - } - - await Program.VoiceroidEngine.TalkAsync(text!); - - await Respond(HttpStatusCode.OK, $"Talked `{text}`."); - } - - private async Task Respond(HttpStatusCode code, string message) - { - var payload = new Dictionary - { - {"success", code == HttpStatusCode.OK}, - {"message", message}, - }; - var content = JsonConvert.SerializeObject(payload, Formatting.Indented); - - await writer.WriteLineAsync(content); - - response.StatusCode = (int) code; - } - } -} diff --git a/SimpleVoiceroid2Proxy/HttpServer.cs b/SimpleVoiceroid2Proxy/HttpServer.cs deleted file mode 100644 index e556fb8..0000000 --- a/SimpleVoiceroid2Proxy/HttpServer.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Diagnostics; -using System.Net; -using System.Threading.Tasks; - -namespace SimpleVoiceroid2Proxy -{ - internal class HttpServer : IDisposable - { - private const int Port = 4532; - - private readonly HttpListener listener = new HttpListener(); - - public HttpServer() - { - listener.Prefixes.Add($"http://+:{Port}/"); - Program.Logger.Info($"http://localhost:{Port} でアクセスできます。GET /talk?text=... または POST /talk `{{\"text\": \"...\"}}` で発話させることができます。"); - - OpenPort(); - } - - public void Dispose() - { - listener.Close(); - } - - private static void OpenPort() - { - using var process = Process.Start(new ProcessStartInfo - { - FileName = "netsh", - Arguments = $"http add urlacl url=http://+:{Port}/ user=Everyone", - Verb = "runas", - WindowStyle = ProcessWindowStyle.Minimized, - }); - process?.WaitForExit(); - - Program.Logger.Info($"ポート: {Port}/tcp を開放しました。ローカルネットワークから Voiceroid2Proxy にアクセスできます。"); - } - - public async Task ConsumeAsync() - { - listener.Start(); - - while (listener.IsListening) - { - try - { - var context = await listener.GetContextAsync(); - - var request = new HttpRequest(context); - await request.HandleAsync(); - } - catch - { - return; - } - } - } - } -} diff --git a/SimpleVoiceroid2Proxy/Logger.cs b/SimpleVoiceroid2Proxy/Logger.cs new file mode 100644 index 0000000..8442b03 --- /dev/null +++ b/SimpleVoiceroid2Proxy/Logger.cs @@ -0,0 +1,51 @@ +using System; + +namespace SimpleVoiceroid2Proxy; + +public sealed class ConsoleLogger : ILogger +{ + public static readonly ILogger Instance = new ConsoleLogger(); + + public void Debug(string message) + { +#if DEBUG + Log(LogLevel.DEBUG, message); +#endif + } + + public void Info(string message) + { + Log(LogLevel.INFO, message); + } + + public void Warn(string message) + { + Log(LogLevel.WARN, message); + } + + public void Error(Exception exception, string message) + { + Log(LogLevel.ERROR, $"{message}\n{exception}"); + } + + private void Log(LogLevel level, string message) + { + Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}"); + } +} + +public interface ILogger +{ + public void Debug(string message); + public void Info(string message); + public void Warn(string message); + public void Error(Exception exception, string message); +} + +public enum LogLevel +{ + DEBUG, + INFO, + WARN, + ERROR, +} diff --git a/SimpleVoiceroid2Proxy/Program.cs b/SimpleVoiceroid2Proxy/Program.cs index c379347..6b3dc35 100644 --- a/SimpleVoiceroid2Proxy/Program.cs +++ b/SimpleVoiceroid2Proxy/Program.cs @@ -1,83 +1,23 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; +using System.Threading.Tasks; +using SimpleVoiceroid2Proxy.Server; -namespace SimpleVoiceroid2Proxy -{ - public static class Program - { - static Program() - { - KillDuplicatedProcesses(); - } - - public static readonly ILogger Logger = new LoggerImpl(); - private static readonly HttpServer Server = new(); - public static readonly VoiceroidEngine VoiceroidEngine = new(); - - public static void Main() - { - Task.Run(async () => - { - await VoiceroidEngine.TalkAsync("準備完了!"); - await Server.ConsumeAsync(); - }).Wait(); - } - - private static void KillDuplicatedProcesses() - { - var currentProcess = Process.GetCurrentProcess(); - var imageName = Assembly.GetExecutingAssembly() - .Location - .Split(Path.DirectorySeparatorChar) - .Last() - .Replace(".exe", ""); +namespace SimpleVoiceroid2Proxy; - foreach (var process in Process.GetProcessesByName(imageName).Where(x => x.Id != currentProcess.Id)) - { - try - { - process.Kill(); - Logger.Info($"{imageName}.exe (PID: {process.Id}) has been killed."); - } - catch - { - Logger.Warn($"Failed to kill {imageName}.exe (PID: {process.Id})."); - } - } - } - - private class LoggerImpl : ILogger - { - public void Info(string message) - { - Write("Info", message); - } - - public void Warn(string message) - { - Write("Warn", message); - } - - public void Error(Exception exception) - { - Write("Error", exception.ToString()); - } +public static class Program +{ + private static readonly HttpServer server = new(); + public static readonly VoiceroidEngine VoiceroidEngine = new(); - private static void Write(string level, string message) - { - Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{level}] {message}"); - } - } + static Program() + { + Utils.KillDuplicateProcesses(); } - public interface ILogger + public static void Main() { - public void Info(string message); - public void Warn(string message); - public void Error(Exception exception); + Task.WaitAll( + server.ListenAsync(), + VoiceroidEngine.TalkAsync("準備完了!") + ); } } diff --git a/SimpleVoiceroid2Proxy/Server/Controller.cs b/SimpleVoiceroid2Proxy/Server/Controller.cs new file mode 100644 index 0000000..e71268e --- /dev/null +++ b/SimpleVoiceroid2Proxy/Server/Controller.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; + +namespace SimpleVoiceroid2Proxy.Server; + +public sealed class Controller +{ + public async Task HandleAsync(HttpContext context) + { + try + { + using (context) + { + switch (context.Request.Url.AbsolutePath) + { + case "/talk" when context.Request.HttpMethod == "GET": + await HandleGetTalkAsync(context); + return; + case "/talk" when context.Request.HttpMethod == "POST": + await HandlePostTalkAsync(context); + return; + case "/talk" when context.Request.HttpMethod == "OPTIONS": + HandleOptionsTalk(context); + return; + default: + await context.RespondJson(HttpStatusCode.NotFound, new Dictionary{ + {"success", false}, + {"message", "not found."}, + }); + return; + } + } + } + catch (Exception exception) + { + ConsoleLogger.Instance.Error(exception, "internal server error occurred"); + + await context.RespondJson(HttpStatusCode.InternalServerError, new Dictionary{ + {"success", false}, + {"message", "internal server error occurred."}, + }); + } + } + + private async Task HandleGetTalkAsync(HttpContext context) + { + await HandleTalkAsync(context, context.Query["text"]); + } + + private async Task HandlePostTalkAsync(HttpContext context) + { + var payload = await JsonSerializer.DeserializeAsync>(context.Request.InputStream); + await HandleTalkAsync(context, (string?)payload!["text"]); + } + + private async Task HandleTalkAsync(HttpContext context, string? text) + { + context.Response.AddHeader("Content-Type", "application/json"); + context.Response.AddHeader("Access-Control-Allow-Origin", "*"); + context.Response.AddHeader("Access-Control-Allow-Method", "GET,POST"); + context.Response.AddHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (string.IsNullOrWhiteSpace(text)) + { + await context.RespondJson(HttpStatusCode.BadRequest, new Dictionary + { + {"success", false}, + {"message", "`text` parameter is null or empty."}, + }); + return; + } + + await Program.VoiceroidEngine.TalkAsync(text!); + await context.RespondJson(HttpStatusCode.OK, new Dictionary + { + {"success", true}, + {"message", $"Talked `{text}`."}, + {"text", text}, + }); + } + + private void HandleOptionsTalk(HttpContext context) + { + context.Response.AddHeader("Access-Control-Allow-Origin", "*"); + context.Response.AddHeader("Access-Control-Allow-Method", "GET, POST, OPTIONS"); + context.Response.AddHeader("Access-Control-Allow-Headers", "Content-Type"); + context.Response.AddHeader("Access-Control-Max-Age", "7200"); + context.Response.StatusCode = (int)HttpStatusCode.NoContent; + } +} diff --git a/SimpleVoiceroid2Proxy/Server/HttpContext.cs b/SimpleVoiceroid2Proxy/Server/HttpContext.cs new file mode 100644 index 0000000..8f3b4ef --- /dev/null +++ b/SimpleVoiceroid2Proxy/Server/HttpContext.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Web; + +namespace SimpleVoiceroid2Proxy.Server; + +public sealed class HttpContext(HttpListenerContext Context) : IDisposable +{ + public HttpListenerRequest Request => Context.Request; + public HttpListenerResponse Response => Context.Response; + public NameValueCollection Query => HttpUtility.ParseQueryString(Request.Url.Query, Encoding.UTF8); + + public async Task RespondJson(HttpStatusCode code, Dictionary payload) + { + await JsonSerializer.SerializeAsync(Context.Response.OutputStream, payload); + Response.StatusCode = (int)code; + } + + public void Dispose() + { + Response.Close(); + } +} diff --git a/SimpleVoiceroid2Proxy/Server/HttpServer.cs b/SimpleVoiceroid2Proxy/Server/HttpServer.cs new file mode 100644 index 0000000..387a785 --- /dev/null +++ b/SimpleVoiceroid2Proxy/Server/HttpServer.cs @@ -0,0 +1,43 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +namespace SimpleVoiceroid2Proxy.Server; + +public sealed class HttpServer : IDisposable +{ + private const int Port = 4532; + + private readonly HttpListener listener = new(); + private readonly Controller controller = new(); + + public HttpServer() + { + listener.Prefixes.Add($"http://+:{Port}/"); + } + + public async Task ListenAsync() + { + listener.Start(); + + while (listener.IsListening) + { + try + { + var context = await listener.GetContextAsync(); + + var request = new HttpContext(context); + await controller.HandleAsync(request); + } + catch + { + return; + } + } + } + + public void Dispose() + { + listener.Close(); + } +} diff --git a/SimpleVoiceroid2Proxy/SimpleVoiceroid2Proxy.csproj b/SimpleVoiceroid2Proxy/SimpleVoiceroid2Proxy.csproj index e83df0e..382d4b0 100644 --- a/SimpleVoiceroid2Proxy/SimpleVoiceroid2Proxy.csproj +++ b/SimpleVoiceroid2Proxy/SimpleVoiceroid2Proxy.csproj @@ -12,7 +12,7 @@ - + diff --git a/SimpleVoiceroid2Proxy/Utils.cs b/SimpleVoiceroid2Proxy/Utils.cs new file mode 100644 index 0000000..3e8c460 --- /dev/null +++ b/SimpleVoiceroid2Proxy/Utils.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace SimpleVoiceroid2Proxy; + +public static class Utils +{ + public static void KillDuplicateProcesses() + { + var currentProcess = Process.GetCurrentProcess(); + var imageName = Assembly.GetExecutingAssembly() + .Location + .Split(Path.DirectorySeparatorChar) + .Last() + .Replace(".exe", ""); + + foreach (var process in Process.GetProcessesByName(imageName)) + { + if (process.Id != currentProcess.Id) + { + process.Kill(); + process.WaitForExit(); + } + } + } +} diff --git a/SimpleVoiceroid2Proxy/VoiceroidEngine.cs b/SimpleVoiceroid2Proxy/VoiceroidEngine.cs index a3b07c7..1ba8fb9 100644 --- a/SimpleVoiceroid2Proxy/VoiceroidEngine.cs +++ b/SimpleVoiceroid2Proxy/VoiceroidEngine.cs @@ -12,191 +12,190 @@ using Microsoft.Win32; using RM.Friendly.WPFStandardControls; -namespace SimpleVoiceroid2Proxy +namespace SimpleVoiceroid2Proxy; + +public class VoiceroidEngine : IDisposable { - public class VoiceroidEngine : IDisposable - { - private readonly Process process; - private readonly WindowsAppFriend app; + private readonly Process process; + private readonly WindowsAppFriend app; - private readonly WPFTextBox talkTextBox; - private readonly WPFButtonBase playButton; - private readonly WPFButtonBase stopButton; - private readonly WPFButtonBase moveButton; + private readonly WPFTextBox talkTextBox; + private readonly WPFButtonBase playButton; + private readonly WPFButtonBase stopButton; + private readonly WPFButtonBase moveButton; - private readonly Channel queue = Channel.CreateUnbounded(); - private static readonly TimeSpan TalkCooldown = TimeSpan.FromMilliseconds(200); - private DateTime lastPlay; - private volatile bool interrupt = true; - private volatile bool paused; + private readonly Channel queue = Channel.CreateUnbounded(); + private static readonly TimeSpan TalkCooldown = TimeSpan.FromMilliseconds(200); + private DateTime lastPlay; + private volatile bool interrupt = true; + private volatile bool paused; - public VoiceroidEngine() - { - Program.Logger.Info("VOICEROID init started..."); + public VoiceroidEngine() + { + ConsoleLogger.Instance.Info("VOICEROID init started..."); - process = GetOrCreateVoiceroidProcess(); - app = new WindowsAppFriend(process); + process = GetOrCreateVoiceroidProcess(); + app = new WindowsAppFriend(process); - try + try + { + // Timeout = 60 sec + for (var i = 0; i < 1200; i++) { - // Timeout = 60 sec - for (var i = 0; i < 1200; i++) - { - var window = app.FromZTop(); - WinApi.ShowWindow(window.Handle, WinApi.SwMinimize); + var window = app.FromZTop(); + WinApi.ShowWindow(window.Handle, WinApi.SwMinimize); - var tree = window.GetFromTypeFullName("AI.Talk.Editor.TextEditView") - .FirstOrDefault() - ?.LogicalTree(); - if (tree == null) - { - Thread.Sleep(50); - continue; - } + var tree = window.GetFromTypeFullName("AI.Talk.Editor.TextEditView") + .FirstOrDefault() + ?.LogicalTree(); + if (tree == null) + { + Thread.Sleep(50); + continue; + } - var text = tree.ByType().Single(); - var play = tree.ByBinding("PlayCommand").Single(); - var stop = tree.ByBinding("StopCommand").Single(); - // var move = tree.ByBinding("MoveToBeginningCommand").Single(); + var text = tree.ByType().Single(); + var play = tree.ByBinding("PlayCommand").Single(); + var stop = tree.ByBinding("StopCommand").Single(); + // var move = tree.ByBinding("MoveToBeginningCommand").Single(); - talkTextBox = new WPFTextBox(text); - playButton = new WPFButtonBase(play); - stopButton = new WPFButtonBase(stop); - moveButton = new WPFButtonBase(tree[15]); - // moveButton = new WPFButtonBase(move); + talkTextBox = new WPFTextBox(text); + playButton = new WPFButtonBase(play); + stopButton = new WPFButtonBase(stop); + moveButton = new WPFButtonBase(tree[15]); + // moveButton = new WPFButtonBase(move); - Program.Logger.Info("VOICEROID ready!"); - Task.Run(ConsumeAsync); + ConsoleLogger.Instance.Info("VOICEROID ready!"); + Task.Run(ConsumeAsync); - return; - } + return; } - catch (Exception exception) - { - Program.Logger.Error(exception); - throw new ApplicationException("VOICEROID init failed."); - } - - throw new TimeoutException("VOICEROID init timed out."); } - - public void Dispose() + catch (Exception exception) { - queue.Writer.TryComplete(); - app.Dispose(); - process.Kill(); - process.Dispose(); + ConsoleLogger.Instance.Error(exception, "VOICEROID init failed."); + throw new ApplicationException("VOICEROID init failed."); } - private static readonly Regex BracketRegex = new Regex(@"<(?.+?)>", RegexOptions.Compiled); - - public async Task TalkAsync(string text) - { - text = BracketRegex.Replace(text, match => - { - switch (match.Groups["command"].Value) - { - case "clear": - while (queue.Reader.TryRead(out _)) - { - } - - Program.Logger.Info("**********"); - break; - case "pause": - paused = true; - - Program.Logger.Info("********** => Paused."); - break; - case "resume": - paused = false; - - Program.Logger.Info("********** => Resumed."); - break; - case "interrupt_enable": - interrupt = true; - - Program.Logger.Info("********** => Interrupt enabled."); - break; - case "interrupt_disable": - interrupt = false; - - Program.Logger.Info("********** => Interrupt disabled."); - break; - default: - return match.Value; - } + throw new TimeoutException("VOICEROID init timed out."); + } - return string.Empty; - }); + public void Dispose() + { + queue.Writer.TryComplete(); + app.Dispose(); + process.Kill(); + process.Dispose(); + } - await queue.Writer.WriteAsync(text); - } + private static readonly Regex BracketRegex = new(@"<(?.+?)>", RegexOptions.Compiled); - private async Task ConsumeAsync() + public async Task TalkAsync(string text) + { + text = BracketRegex.Replace(text, match => { - while (await queue.Reader.WaitToReadAsync()) + switch (match.Groups["command"].Value) { - while (queue.Reader.TryRead(out var text)) - { - await SpeakAsync(text); - } - } - } + case "clear": + while (queue.Reader.TryRead(out _)) + { + } - private async Task SpeakAsync(string text) - { - // VOICEROID2 が発話中の時は「先頭」ボタンが無効になるので、それを利用して発話中かどうかを判定します - while (!interrupt && !moveButton.IsEnabled) - { - await Task.Delay(50); // spin wait + ConsoleLogger.Instance.Info("**********"); + break; + case "pause": + paused = true; + + ConsoleLogger.Instance.Info("********** => Paused."); + break; + case "resume": + paused = false; + + ConsoleLogger.Instance.Info("********** => Resumed."); + break; + case "interrupt_enable": + interrupt = true; + + ConsoleLogger.Instance.Info("********** => Interrupt enabled."); + break; + case "interrupt_disable": + interrupt = false; + + ConsoleLogger.Instance.Info("********** => Interrupt disabled."); + break; + default: + return match.Value; } - while (paused) - { - await Task.Delay(500); - } + return string.Empty; + }); - var cooldown = TalkCooldown - (DateTime.Now - lastPlay); - if (cooldown.TotalMilliseconds > 0) + await queue.Writer.WriteAsync(text); + } + + private async Task ConsumeAsync() + { + while (await queue.Reader.WaitToReadAsync()) + { + while (queue.Reader.TryRead(out var text)) { - await Task.Delay(cooldown); + await SpeakAsync(text); } + } + } - stopButton.EmulateClick(); - talkTextBox.EmulateChangeText(text); - moveButton.EmulateClick(); - playButton.EmulateClick(); - - lastPlay = DateTime.Now; - Program.Logger.Info($"=> {text}"); + private async Task SpeakAsync(string text) + { + // VOICEROID2 が発話中の時は「先頭」ボタンが無効になるので、それを利用して発話中かどうかを判定します + while (!interrupt && !moveButton.IsEnabled) + { + await Task.Delay(50); // spin wait } - private static Process GetOrCreateVoiceroidProcess() + while (paused) { - return (Process.GetProcessesByName("VoiceroidEditor").FirstOrDefault() ?? Process.Start(new ProcessStartInfo - { - FileName = FindVoiceroidPath(), - WindowStyle = ProcessWindowStyle.Minimized, - }))!; + await Task.Delay(500); } - private static string FindVoiceroidPath() + var cooldown = TalkCooldown - (DateTime.Now - lastPlay); + if (cooldown.TotalMilliseconds > 0) { - return Registry.ClassesRoot - .OpenSubKey(@"Installer\Assemblies") - ?.GetSubKeyNames() - .Where(x => x.EndsWith("VoiceroidEditor.exe")) - .Select(x => x.Replace('|', '\\')) - .FirstOrDefault() ?? throw new ApplicationException("VOICEROID not found."); + await Task.Delay(cooldown); } - private static class WinApi + stopButton.EmulateClick(); + talkTextBox.EmulateChangeText(text); + moveButton.EmulateClick(); + playButton.EmulateClick(); + + lastPlay = DateTime.Now; + ConsoleLogger.Instance.Info($"=> {text}"); + } + + private static Process GetOrCreateVoiceroidProcess() + { + return (Process.GetProcessesByName("VoiceroidEditor").FirstOrDefault() ?? Process.Start(new ProcessStartInfo { - public const int SwMinimize = 6; + FileName = FindVoiceroidPath(), + WindowStyle = ProcessWindowStyle.Minimized, + }))!; + } - [DllImport("user32.dll")] - public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); - } + private static string FindVoiceroidPath() + { + return Registry.ClassesRoot + .OpenSubKey(@"Installer\Assemblies") + ?.GetSubKeyNames() + .Where(x => x.EndsWith("VoiceroidEditor.exe")) + .Select(x => x.Replace('|', '\\')) + .FirstOrDefault() ?? throw new ApplicationException("VOICEROID not found."); + } + + private static class WinApi + { + public const int SwMinimize = 6; + + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); } }