diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bfe6e9f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,75 @@ + +# Introduction +![plot](logo-med.png#right) `pizzawave` is a set of cross-platform .NET tools for processing audio data streamed by the [callstream plugin](https://github.com/lilhoser/callstream) of [trunk-recorder](https://github.com/robotastic/trunk-recorder). The audio data consists of calls recorded by trunk-recorder from conventional and trunked radio systems, such as local fire/rescue/EMS. `pizzawave` tooling transcribes these calls to text using [OpenAI's Whisper AI model](https://openai.com/research/whisper) as exposed through [whisper.net toolchain](https://github.com/sandrohanea/whisper.net). Among other features, the application allows you to monitor and set alerts for keywords of interest. + +The `pizzawave` project consists of these tools: +* A windows-only .NET UI (`pizzaui` in source) +* A cross-platform .NET command line application (`pizzacmd` in source) +* A cross-platform .NET library (`pizzalib` in source), used by the UI and CLI application + +Please be sure to read the README for each individual tool. + +# Requirements + +Regardless of whether you choose to use the UI, command line application, or roll your own application that uses the cross-platform library, you will need to observe these requirements: + +* A Linux system running trunk-recorder with the [callstream plugin](https://github.com/lilhoser/callstream) configured +* An operating system capable of running .NET 8.0 runtime (e.g, Win, Lin or Mac) + * The pizzawave tools currently target .NET 8.0, but if you are building from source, earlier versions should work as well. +* The requirements as specified in the tool of choice: + * `pizzaui`: Windows-only | [README](https://github.com/lilhoser/pizzawave/tree/main/pizzaui) + * `pizzacmd`: All supported platforms | [README](https://github.com/lilhoser/pizzawave/tree/main/pizzacmd) + * `pizzalib`: All supported platforms | [README](https://github.com/lilhoser/pizzawave/tree/main/pizzalib) + +# Architecture + +![plot](pizzawave-architecture.png#right) + +As shown in the illustration, pizzawave uses a `server`-`client` model, where the server is either the pizzawave UI or command line application and the client is one or more trunk-recorder systems. This design allows pizzawave to accept radio transmissions from multiple instances of trunk-recorder, which might be recording audio data from separate SDR device arrays monitoring broadcasts from different trunked radio systems. + +Pizzawave listens for audio data from trunk-recorder systems, translates the data into textual transcriptions using Whisper AI, and processes alert rules to notify you of interesting broadcasts. + +# Building from Source + +## Windows +You can use Visual Studio Community Edition for free. + +## Mac and Linux + +* [Install .NET core](https://learn.microsoft.com/en-us/dotnet/core/install/) +* clone this repo +* CD into repo source +* run `dotnet build --runtime ` where [RID can be found here](https://learn.microsoft.com/en-us/dotnet/core/rid-catalog) + +# Configuration + +Pizzawave configuration lives in `\pizzawave\settings.json`. On Windows, this is `Users\\AppData\Roaming\pizzawave\settings.json`. Please see the READMEs for each individual tool you are using for what settings options are available and how to use them in your setup. `pizzaui` includes a feature that allows you to setup your configuration in a more automated way, but you can always create the file manually. If you run the UI or command line application without a settings file, the default one will be created in the location specified above. + +_Important_: Make sure your `trunk-recorder` system is configured to connect to the right IP address. In an exotic scenario where you're running `pizzacmd` from both a Windows host system and a WSL2 Ubuntu system, the host system and the virtual Ubuntu system will have different IP addresses! In this scenario, you might forget to set the correct IP address on the `trunk-recorder` system, and only one of these machines will receive audio data, while the other might be stuck on this: + +``` +StreamServer Verbose: 1 : 3/22/2024 3:39 PM: Listening on port 9123 +``` + +# Other + +## Diagnostics + +All logs, model files, settings files, and alert data can be found in your operating system's user profile folder. +* `alerts` - this folder contains WAV data for matched alerts +* `Logs` - this folder contains all log files +* `model` - this folder contains all auto-downloaded GGML model files + +If your logs are not detailed enough, adjust the `TraceLevelApp` parameter in `settings.json`. + +## What's up with the name? +I dunno, I like pizza and Teenage Mutant Ninja Turtles, so it seemed to work. + +## Resources + +* If you're struggling to setup trunk-recorder, I recommend [this extremely well-written intro guide](https://www.andrewmohawk.com/2020/06/12/trunked-radio-a-guide/). +* Use [this tool](https://alertapi.alertpage.net/sdr/) to calculate some trunk-recorder configuration parameters like center frequency and to understand how many SDR dongles you will need to cover channels of interest +* Other trunk-recorder related projects performing transcription: + * [trunk-transcribe](https://github.com/CrimeIsDown/trunk-transcribe) + * [trunk-recorder-stack](https://github.com/ge0metrix/trunk-recorder-stack) + * [tr-uploader](https://github.com/TheGreatCodeholio/icad_tr_uploader) and [icad_tone_detection_api](https://github.com/TheGreatCodeholio/icad_tone_detection_api) \ No newline at end of file diff --git a/docs/logo-med.png b/docs/logo-med.png new file mode 100644 index 0000000..69ca11b Binary files /dev/null and b/docs/logo-med.png differ diff --git a/docs/pizzawave-architecture.png b/docs/pizzawave-architecture.png new file mode 100644 index 0000000..6a0e6f4 Binary files /dev/null and b/docs/pizzawave-architecture.png differ diff --git a/docs/screenshot1.png b/docs/screenshot1.png new file mode 100644 index 0000000..6dc120e Binary files /dev/null and b/docs/screenshot1.png differ diff --git a/docs/screenshot2.png b/docs/screenshot2.png new file mode 100644 index 0000000..73a816e Binary files /dev/null and b/docs/screenshot2.png differ diff --git a/docs/screenshot3.png b/docs/screenshot3.png new file mode 100644 index 0000000..969a136 Binary files /dev/null and b/docs/screenshot3.png differ diff --git a/docs/screenshot4.png b/docs/screenshot4.png new file mode 100644 index 0000000..3003201 Binary files /dev/null and b/docs/screenshot4.png differ diff --git a/pizzacmd/Program.cs b/pizzacmd/Program.cs new file mode 100644 index 0000000..cd0d001 --- /dev/null +++ b/pizzacmd/Program.cs @@ -0,0 +1,30 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Reflection; + +namespace pizzacmd; + +class Program +{ + static async Task Main(string[] args) + { + var pizza = new pizzacmd(); + return await pizza.Run(args); + } +} \ No newline at end of file diff --git a/pizzacmd/README.md b/pizzacmd/README.md new file mode 100644 index 0000000..66c6a29 --- /dev/null +++ b/pizzacmd/README.md @@ -0,0 +1,35 @@ + +# Introduction +![plot](../docs/logo-med.png#right) `pizzacmd` is a .NET command line application built on top of the [`pizzalib`]((https://github.com/lilhoser/pizzawave/tree/main/pizzalib) library. + +![plot](../docs/screenshot4.png) + +# Requirements +* [Requirements as specified in the `pizzawave` README](https://github.com/lilhoser/pizzawave) +* [Requirements as specified in the `pizzalib` README]((https://github.com/lilhoser/pizzawave/tree/main/pizzalib) +* A supported operating system (Win, Lin, Mac) running .NET 8 or later + +# Configuration +`pizzacmd` currently has no settings beyond what is contained in `pizzalib`. + +# Running on WSL2 + +## Port forwarding + +Remember that `trunk-recorder` needs to be configured to communicate with the server. If you're running `pizzacmd` from within a linux OS in WSL2 on Windows, you'll need to make sure the WSL2 instance is configured to receive the network traffic: + +``` +netsh interface portproxy add v4tov4 listenport=[PORT] listenaddress=0.0.0.0 connectport=[PORT] connectaddress=[WSL_IP] +``` + +Replace `[PORT]` with your listen port, such as `9123` and `[WSL_IP]` with your WSL instance IP address, eg `172.23.192.16`. + +## Whisper issues + +If you receive an error like this: + +``` +Whisper Error: 1 : 3/22/2024 5:12 PM: Failed to transcribe WAV data: Failed to load native whisper library. Error: Unknown error +``` + +It most likely means you have the wrong `Whisper.net` runtime installed. For linux, you must install either `cublas` or revert to CPU only (`Whisper.net.Runtime`). \ No newline at end of file diff --git a/pizzacmd/Settings.cs b/pizzacmd/Settings.cs new file mode 100644 index 0000000..46589b1 --- /dev/null +++ b/pizzacmd/Settings.cs @@ -0,0 +1,99 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using Newtonsoft.Json; + +namespace pizzacmd +{ + public class Settings : pizzalib.Settings, IEquatable + { + public Settings() : base() + { + } + + public override bool Equals(object? Other) + { + if (Other == null) + { + return false; + } + var field = Other as Settings; + return Equals(field); + } + + public bool Equals(Settings? Other) + { + if (Other == null) + { + return false; + } + return base.Equals(Other); + } + + public static bool operator ==(Settings? Settings1, Settings? Settings2) + { + if ((object)Settings1 == null || (object)Settings2 == null) + return Equals(Settings1, Settings2); + return Settings1.Equals(Settings2); + } + + public static bool operator !=(Settings? Settings1, Settings? Settings2) + { + if ((object)Settings1 == null || (object)Settings2 == null) + return !Equals(Settings1, Settings2); + return !(Settings1.Equals(Settings2)); + } + + public override int GetHashCode() + { + return (this).GetHashCode() + base.GetHashCode(); + } + + public override void Validate() + { + base.Validate(); + } + + public void SaveToFile(string? Target) + { + string target; + + if (string.IsNullOrEmpty(Target)) + { + target = DefaultSettingsFileLocation; + } + else + { + target = Target; + } + + try + { + Validate(); + var json = JsonConvert.SerializeObject(this, Formatting.Indented); + File.WriteAllText(target, json); + } + catch (Exception ex) + { + throw new Exception($"Could not save the Settings object " + + $"to JSON: {ex.Message}"); + } + } + } +} + diff --git a/pizzacmd/TraceLogger.cs b/pizzacmd/TraceLogger.cs new file mode 100644 index 0000000..f011ce2 --- /dev/null +++ b/pizzacmd/TraceLogger.cs @@ -0,0 +1,157 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; + +namespace pizzacmd +{ + public static class TraceLogger + { + public static readonly string m_TraceFileDir = Path.Combine(new string[] {pizzalib.Settings.DefaultWorkingDirectory, "Logs"}); + private static string m_Location = Path.Combine(new string[] { m_TraceFileDir, + $"pizzacmd-{DateTime.Now.ToString("yyyy-MM-dd-HHmmss")}.txt"}); + private static TextWriterTraceListener m_TextWriterTraceListener = + new TextWriterTraceListener(m_Location, "pizzacmdTextWriterListener"); + private static ConsoleTraceListener m_ConsoleTraceListener = new ConsoleTraceListener(); + private static SourceSwitch m_Switch = + new SourceSwitch("pizzacmdSwitch", "Verbose"); + private static TraceSource[] Sources = { + new TraceSource("Main", SourceLevels.Verbose), + }; + + public enum TraceLoggerType + { + Main, + Max + } + + public static void Initialize(bool RedirectToStdout = false) + { + System.Diagnostics.Trace.AutoFlush = true; + foreach (var source in Sources) + { + source.Listeners.Add(m_TextWriterTraceListener); + source.Switch = m_Switch; + if (RedirectToStdout) + { + source.Listeners.Add(m_ConsoleTraceListener); + } + } + + if (Directory.Exists(pizzalib.Settings.DefaultWorkingDirectory)) + { + if (!Directory.Exists(m_TraceFileDir)) + { + try + { + Directory.CreateDirectory(m_TraceFileDir); + } + catch (Exception) // swallow + { + } + } + } + } + + public static void Shutdown() + { + m_TextWriterTraceListener.Close(); + m_ConsoleTraceListener.Close(); + } + + public static void SetLevel(SourceLevels Level) + { + m_Switch.Level = Level; + } + + public static void Trace(TraceLoggerType Type, TraceEventType EventType, string Message) + { + if (Type >= TraceLoggerType.Max) + { + throw new Exception("Invalid logger type"); + } + using (GetColorContext(EventType)) + { + Sources[(int)Type].TraceEvent(EventType, 1, $"{DateTime.Now:M/d/yyyy h:mm tt}: {Message}"); + } + } + + private static ColorContext GetColorContext(TraceEventType eventType) + { + switch (eventType) + { + case TraceEventType.Verbose: + return new ColorContext(ConsoleColor.DarkGray); + case TraceEventType.Information: + return new ColorContext(ConsoleColor.Gray); + case TraceEventType.Critical: + return new ColorContext(ConsoleColor.DarkRed); + case TraceEventType.Error: + return new ColorContext(ConsoleColor.Red); + case TraceEventType.Warning: + return new ColorContext(ConsoleColor.Yellow); + case TraceEventType.Start: + return new ColorContext(ConsoleColor.DarkGreen); + case TraceEventType.Stop: + return new ColorContext(ConsoleColor.DarkMagenta); + case TraceEventType.Transfer: + return new ColorContext(ConsoleColor.DarkYellow); + default: + return new ColorContext(); + } + } + } + + internal sealed class ColorContext : IDisposable + { + private readonly ConsoleColor previousBackgroundColor; + private readonly ConsoleColor previousForegroundColor; + private bool isDisposed; + + public ColorContext() + : this(Console.ForegroundColor, Console.BackgroundColor) + { + } + + public ColorContext(ConsoleColor foregroundColor) + : this(foregroundColor, Console.BackgroundColor) + { + } + + public ColorContext(ConsoleColor foregroundColor, ConsoleColor backgroundColor) + { + this.isDisposed = false; + this.previousForegroundColor = Console.ForegroundColor; + this.previousBackgroundColor = Console.BackgroundColor; + Console.ForegroundColor = foregroundColor; + Console.BackgroundColor = backgroundColor; + } + + public void Dispose() + { + if (this.isDisposed) + { + return; + } + + Console.ForegroundColor = this.previousForegroundColor; + Console.BackgroundColor = this.previousBackgroundColor; + this.isDisposed = true; + } + } +} diff --git a/pizzacmd/banner.txt b/pizzacmd/banner.txt new file mode 100644 index 0000000..b6ee020 --- /dev/null +++ b/pizzacmd/banner.txt @@ -0,0 +1,28 @@ + + #%%%# %%%%%%%% + %%%* %%%%%%%%%%%%%%%%%%% + %%#%@%%%% %%%%% + %%*%%%%%%% # %%%%%% %%%% %%%%% + # %#%# %% %%% %%% + * %*=* @ ##%%% %%% %%% + %% %% % # %%% %%% %% + %%% %@%#*==+#%%%%%*=-=+#% %% %% %% + @ %@*-=----==--:::-==-=#@ %% %%% %%@%% + #####++-==-=***##+-##**#=+*#% %% %%@ %%%% + %+-=------=##::--:::::--==-::-% %% @%%@%%@% + %%=--:+=+**#*#+-:-++*++*-::=-*#-=% %%@@%% %%% + %%%%*+#=*#+-=*=--=***+#*+*+-::::::*% %% %%%# %%% + @#%#-+#+#*--=+#-:=#*-#*#%#*+:*-::+=#% % % %% + @%+--+*#*-:::::::-*##*#***#=:-=+*+=+# %%%% %% + @%#*==+**--::-===-==+*+-::#****#***% # %% % + % @@%%@%++--::::--+-::-::#***#+**+#% %% %% + % %=--*#*++#*::--=-:-#***+#*#+-# # %% + @% %%-*%%%#*=-=*+=:----:-+**+=:==# % + %=# %%%*=--++----::-::-==-* % + %*# %%% %%%%+--=*-+--::---:+%%% + #* % %==%%==-+==+-:-:::# %@ + % %-+% %%#+-=+=-::-:=% % + %-=% %%#+==+--::#% + %#%## %%*-==-:+% + % %%%*-+*% + %%@% \ No newline at end of file diff --git a/pizzacmd/pizzacmd.cs b/pizzacmd/pizzacmd.cs new file mode 100644 index 0000000..0f53212 --- /dev/null +++ b/pizzacmd/pizzacmd.cs @@ -0,0 +1,227 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using Newtonsoft.Json; +using pizzalib; +using System.Diagnostics; +using System.Reflection; +using System.Text; + +namespace pizzacmd +{ + using static TraceLogger; + using Whisper = pizzalib.Whisper; + + internal class pizzacmd + { + private StreamServer? m_StreamServer; + private Whisper? m_Whisper; + private Alerter? m_Alerter; + private Settings? m_Settings; + + public pizzacmd() + { + } + + public async Task Run(string[] Args) + { + TraceLogger.Initialize(true); + pizzalib.TraceLogger.Initialize(true); + WriteBanner(); + // + // Look. I've tried Microsoft's System.CommandLine for parsing and it's simply + // awful, awful awful. So this is all you'll get and YOU'LL LIKE IT. + // + string settingsPath = pizzalib.Settings.DefaultSettingsFileLocation; + foreach (var arg in Args) + { + if (arg.ToLower().StartsWith("--settings") || + arg.ToLower().StartsWith("-settings")) + { + if (!arg.Contains('=')) + { + Console.WriteLine($"Invalid settings file: {arg}"); + Console.WriteLine("Usage: pizzacmd.exe --settings="); + return 1; + } + var pieces = arg.Split('='); + if (pieces.Length != 2) + { + Console.WriteLine($"Invalid settings file: {arg}"); + Console.WriteLine("Usage: pizzacmd.exe --settings="); + return 1; + } + settingsPath = pieces[1]; + if (!File.Exists(settingsPath)) + { + Console.WriteLine($"Settings file doesn't exist: {settingsPath}"); + Console.WriteLine("Usage: pizzacmd.exe --settings="); + return 1; + } + break; + } + else if (arg.ToLower().StartsWith("--help") || arg.ToLower().StartsWith("-help")) + { + Console.WriteLine("Usage: pizzacmd.exe --settings="); + return 0; + } + else + { + Console.WriteLine($"Unknown argument {arg}"); + Console.WriteLine("Usage: pizzacmd.exe --settings="); + return 1; + } + } + var result = await Initialize(settingsPath); + if (!result) + { + return 1; + } + result = await StartServer(); // blocks until CTRL+C + TraceLogger.Shutdown(); + return result ? 0 : 1; + } + + private async Task Initialize(string SettingsPath) + { + Trace(TraceLoggerType.Main, + TraceEventType.Information, + $"Init: Using settings {SettingsPath}"); + if (!File.Exists(SettingsPath)) + { + Trace(TraceLoggerType.Main, + TraceEventType.Warning, + $"Settings file {SettingsPath} does not exist, loading default..."); + m_Settings = new Settings(); + m_Settings.SaveToFile(SettingsPath); // persist it + } + else + { + try + { + var json = File.ReadAllText(SettingsPath); + m_Settings = (Settings)JsonConvert.DeserializeObject(json, typeof(Settings))!; + } + catch (Exception ex) + { + Trace(TraceLoggerType.Main, TraceEventType.Error, $"{ex.Message}"); + return false; + } + } + + TraceLogger.SetLevel(m_Settings.TraceLevelApp); + pizzalib.TraceLogger.SetLevel(m_Settings.TraceLevelApp); + + // + // Use Console directly here in case trace isn't verbose enough. + // + Console.WriteLine($"Init: trace level {m_Settings.TraceLevelApp}"); + + try + { + m_Whisper = new Whisper(m_Settings); + m_Alerter = new Alerter(m_Settings, m_Whisper, NewCallTranscribed); + m_StreamServer = new StreamServer(m_Alerter.NewCallDataAvailable, m_Settings); + _ = await m_Whisper.Initialize(); + } + catch (Exception ex) + { + Trace(TraceLoggerType.Main, TraceEventType.Error, $"{ex.Message}"); + return false; + } + + Trace(TraceLoggerType.Main, TraceEventType.Verbose, "Init: Complete"); + + return true; + } + + private async Task StartServer() + { + try + { + Console.CancelKeyPress += (sender, eventArgs) => { + eventArgs.Cancel = true; + Trace(TraceLoggerType.Main, TraceEventType.Information, "Server shutting down..."); + m_StreamServer!.Shutdown(); + }; + _ = await m_StreamServer!.Listen(); // blocks until CTRL+C + } + catch (Exception ex) + { + Trace(TraceLoggerType.Main, TraceEventType.Error, $"{ex.Message}"); + return false; + } + + return true; + } + + private void NewCallTranscribed(TranscribedCall Call) + { + Trace(TraceLoggerType.Main, TraceEventType.Verbose, $"{Call.ToString(m_Settings!)}"); + + var jsonContents = new StringBuilder(); + try + { + var jsonObject = JsonConvert.SerializeObject(Call, Formatting.Indented); + jsonContents.AppendLine(jsonObject); + } + catch (Exception ex) + { + Trace(TraceLoggerType.Main, + TraceEventType.Error, + $"Failed to create JSON: {ex.Message}"); + return; + } + try + { + var target = Path.Combine(pizzalib.Settings.DefaultWorkingDirectory, + pizzalib.Settings.DefaultCallLogFileName); + using (var writer = new StreamWriter(target, true, Encoding.UTF8)) + { + writer.WriteLine(jsonContents.ToString()); + } + } + catch (Exception ex) + { + Trace(TraceLoggerType.Main, + TraceEventType.Error, + $"Failed to save JSON: {ex.Message}"); + return; + } + } + + private static void WriteBanner() + { + var version = Assembly.GetExecutingAssembly().GetName().Version; + var assembly = Assembly.GetExecutingAssembly(); + string resourceName = assembly.GetManifestResourceNames().Single( + str => str.EndsWith("banner.txt")); + string banner = string.Empty; + using (Stream stream = assembly.GetManifestResourceStream(resourceName)!) + using (StreamReader reader = new StreamReader(stream)) + { + banner = reader.ReadToEnd(); + } + var separator = "---------------------------------------------------"; + + Console.WriteLine(banner); + Trace(TraceLoggerType.Main, TraceEventType.Warning, $"pizzacmd {version}"); + Trace(TraceLoggerType.Main, TraceEventType.Information, separator); + } + } +} diff --git a/pizzacmd/pizzacmd.csproj b/pizzacmd/pizzacmd.csproj new file mode 100644 index 0000000..4c30c3e --- /dev/null +++ b/pizzacmd/pizzacmd.csproj @@ -0,0 +1,26 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/pizzalib/Alert.cs b/pizzalib/Alert.cs new file mode 100644 index 0000000..7d53dff --- /dev/null +++ b/pizzalib/Alert.cs @@ -0,0 +1,144 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Net.Mail; + +namespace pizzalib +{ + public enum AlertFrequency + { + RealTime, + Hourly, + Daily + } + + public class Alert : IEquatable + { + public Guid Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string Keywords { get; set; } + public AlertFrequency Frequency { get; set; } + public List Talkgroups { get; set; } + public bool CaptureWAV { get; set; } + public bool Enabled { get; set; } + + public Alert() + { + Name = string.Empty; + Email = string.Empty; + Keywords = string.Empty; + Id = Guid.NewGuid(); + Talkgroups = new List(); + } + + public override string ToString() + { + return $"{Name}({(Enabled ? "on" : "off")}: {Keywords}"; + } + + public override bool Equals(object? Other) + { + if (Other == null) + { + return false; + } + var field = Other as Alert; + return Equals(field); + } + + public bool Equals(Alert? Other) + { + if (Other == null) + { + return false; + } + return Id == Other.Id && + Name == Other.Name && + Email == Other.Email && + Keywords == Other.Keywords && + Frequency == Other.Frequency && + Talkgroups == Other.Talkgroups && + CaptureWAV == Other.CaptureWAV && + Enabled == Other.Enabled; + } + + public static bool operator ==(Alert? Alert1, Alert? Alert2) + { + if ((object)Alert1 == null || (object)Alert2 == null) + return Equals(Alert1, Alert2); + return Alert1.Equals(Alert2); + } + + public static bool operator !=(Alert? Alert1, Alert? Alert2) + { + if ((object)Alert1 == null || (object)Alert2 == null) + return !Equals(Alert1, Alert2); + return !(Alert1.Equals(Alert2)); + } + + public override int GetHashCode() + { + return (Name, Email, Keywords, Frequency, Talkgroups, CaptureWAV, Enabled + ).GetHashCode(); + } + + public void Validate() + { + if (string.IsNullOrEmpty(Name)) + { + throw new Exception("Name is required"); + } + + if (string.IsNullOrEmpty(Email)) + { + throw new Exception("Email is required"); + } + if (string.IsNullOrEmpty(Keywords)) + { + throw new Exception("Keywords are required"); + } + var emails = GetEmailRecipients(); + if (emails.Count == 0) + { + throw new Exception("Email is required"); + } + foreach (var email in emails) + { + try + { + var m = new MailAddress(email); + } + catch (FormatException ex) + { + throw new Exception($"Alert email address {email} is invalid: {ex.Message}"); + } + } + } + + public List GetEmailRecipients() + { + var emails = Email.Split(',', StringSplitOptions.RemoveEmptyEntries); + if (emails.Length == 0) + { + return new List(); + } + return emails.ToList(); + } + } +} diff --git a/pizzalib/AlertEvent.cs b/pizzalib/AlertEvent.cs new file mode 100644 index 0000000..6d2d0c7 --- /dev/null +++ b/pizzalib/AlertEvent.cs @@ -0,0 +1,69 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +namespace pizzalib +{ + public class AlertEvent + { + public Guid AlertId { get; set; } + public Guid Id { get; set; } + public DateTime LastTriggered { get; set; } + public int TriggerCount; + public int TriggerCountLastInterval; + private readonly ReaderWriterLockSlim Lock; + + // + // This "interval" is every 5 seconds. Meant to catch spammy alerts. + // So the logical limit is 1 alert/email every 5 seconds, PER alert rule. + // + public static readonly int s_RealtimeIntervalSec = 5; + public static readonly int s_RealtimeThresholdPerInterval = 1; + + public AlertEvent(Guid AlertId_) + { + AlertId = AlertId_; + Id = Guid.NewGuid(); + LastTriggered = DateTime.MinValue; + TriggerCount = 0; + TriggerCountLastInterval = 0; + Lock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion); + } + + public void LockExclusive() + { + Lock.EnterWriteLock(); + } + + public void LockShared() + { + Lock.EnterReadLock(); + } + + public void Unlock() + { + if (Lock.IsWriteLockHeld) + { + Lock.ExitWriteLock(); + } + else if (Lock.IsReadLockHeld) + { + Lock.ExitReadLock(); + } + } + } +} diff --git a/pizzalib/Alerter.cs b/pizzalib/Alerter.cs new file mode 100644 index 0000000..107f695 --- /dev/null +++ b/pizzalib/Alerter.cs @@ -0,0 +1,250 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; +using System.Net.Mail; +using System.Net.Mime; +using System.Net; +using static pizzalib.TraceLogger; + +namespace pizzalib +{ + public class Alerter + { + private Action? TranscriptionCompleteCallback; + private Whisper m_Whisper; + private Dictionary m_AlertEvents; + private Settings m_Settings; + + public Alerter( + Settings Settings, + Whisper WhisperInstance, + Action? Callback) + { + m_Settings = Settings; + m_Whisper = WhisperInstance; + m_AlertEvents = new Dictionary(); + TranscriptionCompleteCallback = Callback; + } + + public async Task NewCallDataAvailable(WavStreamData Data) + { + // + // This routine is a callback invoked from a worker thread in StreamServer.cs + // It is safe/OK to perform blocking calls here. + // NOTE: This method is invoked PER CALL, and calls can happen in parallel. + // + try + { + var wavLocation = string.Empty; + if (!string.IsNullOrEmpty(m_Settings.WavFileLocation)) + { + var baseDir = m_Settings.WavFileLocation; + var fileName = $"audio-{DateTime.Now:yyyy-MM-dd-HHmmss}.mp3"; + Data.DumpStreamToFile(baseDir, fileName, OutputFileFormat.Mp3); + wavLocation = Path.Combine(baseDir, fileName); + } + var call = await m_Whisper.TranscribeCall(Data); + call.Location = wavLocation; + ProcessAlerts(call, Data); + TranscriptionCompleteCallback?.Invoke(call); + } + catch (Exception ex) + { + Trace(TraceLoggerType.Alerts, TraceEventType.Error, $"{ex.Message}"); + throw; // back up to worker thread + } + } + + private void ProcessAlerts(TranscribedCall Call, WavStreamData CallWavData) + { + foreach (var alert in m_Settings.Alerts) + { + Trace(TraceLoggerType.Alerts, + TraceEventType.Verbose, + $"Processing alert {alert.Name} for call ID {Call.CallId}"); + if (!alert.Enabled) + { + Trace(TraceLoggerType.Alerts, + TraceEventType.Information, + $"Alert {alert.Name} is disabled, skipping."); + continue; + } + var keywords = alert.Keywords.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList(); + foreach (var keyword in keywords) + { + if (Call.Transcription.ToLower().Contains(keyword.ToLower())) + { + Trace(TraceLoggerType.Alerts, + TraceEventType.Information, + $"Alert {alert.Name} keyword {keyword} matches call ID {Call.CallId} transcription."); + Call.IsAlertMatch = true; + TriggerAlertEvent(alert, Call, CallWavData); + } + } + } + } + + private void TriggerAlertEvent(Alert Alert, TranscribedCall Call, WavStreamData CallWavData) + { + // + // If this alert has triggered before, pull its record, otherwise create + // create a new event tracker. + // + AlertEvent alertEvent; + if (m_AlertEvents.ContainsKey(Alert.Id)) + { + alertEvent = m_AlertEvents[Alert.Id]; + } + else + { + alertEvent = new AlertEvent(Alert.Id); + } + + // + // Because this function can be invoked by multiple threads processing different + // calls, we must acquire the AlertEvent lock exclusive (writer). + // + alertEvent.LockExclusive(); + + try + { + if (alertEvent.LastTriggered != DateTime.MinValue) + { + int diff; + switch (Alert.Frequency) + { + case AlertFrequency.Daily: + { + diff = (int)(DateTime.Now - alertEvent.LastTriggered).TotalDays; + break; + } + case AlertFrequency.Hourly: + { + diff = (int)(DateTime.Now - alertEvent.LastTriggered).TotalHours; + break; + } + case AlertFrequency.RealTime: + { + diff = (int)(DateTime.Now - alertEvent.LastTriggered).TotalSeconds; + if (alertEvent.TriggerCountLastInterval > AlertEvent.s_RealtimeThresholdPerInterval) + { + if (diff <= AlertEvent.s_RealtimeIntervalSec) + { + Trace(TraceLoggerType.Alerts, + TraceEventType.Warning, + $"NOT triggering: Alert set to real-time has been triggered " + + $"{alertEvent.TriggerCountLastInterval} times in the past {diff} seconds."); + return; + } + alertEvent.TriggerCountLastInterval = 0; // reset the count + } + diff = 1; + break; + } + default: + { + Trace(TraceLoggerType.Alerts, + TraceEventType.Error, + $"Unrecognized alert frequency {Alert.Frequency}"); + return; + } + } + + if (diff < 1) + { + Trace(TraceLoggerType.Alerts, + TraceEventType.Warning, + $"NOT triggering: Alert set to {Alert.Frequency}, last triggered " + + $"{alertEvent.LastTriggered:M/d/yyyy h:mm tt}"); + return; + } + } + + alertEvent.LastTriggered = DateTime.Now; + alertEvent.TriggerCountLastInterval++; + + if (Alert.CaptureWAV) + { + // + // Only write the audio file once - either this was already done because the global + // setting to capture MP3 files was enabled, or a prior alert triggered for + // this call and wrote it. + // + if (string.IsNullOrEmpty(Call.Location)) + { + var fileName = $"alert-audio-{DateTime.Now:yyyy-MM-dd-HHmmss}.mp3"; + CallWavData.DumpStreamToFile( + Settings.DefaultAlertWavLocation, fileName, OutputFileFormat.Mp3); + Call.Location = Path.Combine(Settings.DefaultAlertWavLocation, fileName); + } + } + + if (!string.IsNullOrEmpty(Alert.Email) && + !string.IsNullOrEmpty(m_Settings.gmailUser) && + !string.IsNullOrEmpty(m_Settings.gmailPassword)) + { + alertEvent.Unlock(); // don't hold lock, email could block + SendEmailNotification(Alert, alertEvent, Call); + } + } + finally + { + alertEvent.Unlock(); + } + } + + private void SendEmailNotification(Alert Alert, AlertEvent AlertEvent, TranscribedCall Call) + { + var formattedTalkgroup = TalkgroupHelper.FormatTalkgroup(m_Settings, Call.Talkgroup); + var sender = new MailAddress(m_Settings.gmailUser!, "pizzawave notifications"); + string password = m_Settings.gmailPassword!; + var smtp = new SmtpClient + { + Host = "smtp.gmail.com", + Port = 587, + EnableSsl = true, + DeliveryMethod = SmtpDeliveryMethod.Network, + Credentials = new NetworkCredential(sender.Address, password), + Timeout = 20000 + }; + var recipients = Alert.GetEmailRecipients(); + + foreach (var recipient in recipients) + { + var recipientAddress = new MailAddress(recipient, null); + using (var message = new MailMessage(sender, recipientAddress) + { + Subject = $"pizzawave alert: {Alert.Name}", + IsBodyHtml = true, + Body = $"The following audio transcription from talkgroup {formattedTalkgroup}" + + $" has triggered your alert named " + + $"{Alert.Name} on {AlertEvent.LastTriggered:M/d/yyyy h:mm tt}:" + + $"

{Call.Transcription}

" + }) + { + var contentType = new ContentType(); + contentType.MediaType = MediaTypeNames.Application.Octet; + contentType.Name = Path.GetFileName(Call.Location); + message.Attachments.Add(new Attachment(Call.Location, contentType)); + smtp.Send(message); + } + } + } + } +} diff --git a/pizzalib/README.md b/pizzalib/README.md new file mode 100644 index 0000000..0c7c507 --- /dev/null +++ b/pizzalib/README.md @@ -0,0 +1,87 @@ + +# Introduction +![plot](../docs/logo-med.png#right) `pizzalib` is a standalone, cross-platform .NET library that provides the following capabilities to a containing application: +* Integration with [callstream plugin](https://github.com/lilhoser/callstream) of [trunk-recorder](https://github.com/robotastic/trunk-recorder) to receive audio data +* Running a simple server that receives and process audio data in preparation for transcription +* Audio to text transcription using [OpenAI's Whisper AI model](https://openai.com/research/whisper) as exposed through [whisper.net toolchain](https://github.com/sandrohanea/whisper.net) +* Email alerting +* Management of talkgroup data and alerting rules + +Please be sure to read the [`pizzawave` README page](https://github.com/lilhoser/pizzawave) + +# Requirements +* [Requirements as specified in the `pizzawave` README](https://github.com/lilhoser/pizzawave) +* A supported operating system (Win, Lin, Mac) running .NET 8 or later +* A .NET program that uses `pizzalib` as a reference (see `pizzaui` and `pizzacmd` as examples) +* The requirements for transcription using AI are discussed in the `Using Whisper for Transcription` section. + +# Using Whisper for Transcription + +`pizzalib` uses OpenAI's Whisper model for audio transcription. While out of the box, both the UI and command line applications require an Nvidia GPU with [CUDA Toolkit](https://developer.nvidia.com/cuda-downloads) installed, you have some options to reconfigure this. Keep reading. + +## Switching compute backend + +An NVIDIA GPU is currently required, because the underlying Whisper.net library [does not support](https://github.com/SciSharp/LLamaSharp/issues/264) dynamically choosing a compute backend. However, don't despair: it is fairly straightforward to run pizzwave on your CPU: +* Clone this repo +* Open the solution file in Visual Studio (you can use free Community Edition) +* Open nuget package manager `pizzalib` project +* Uninstall `Whisper.net.runtime.cublas` +* Install `Whisper.net.runtime` (CPU only), `Whisper.net.Runtime.CoreML` (Mac CoreML), or `Whisper.net.Runtime.Clblast` (Linux/Windows) +* Rebuild + +## Switching base model + +By default, `pizzalib` will attempt to download the base whisper GGML model using [Whisper.net's downloader](https://github.com/sandrohanea/whisper.net/blob/main/Whisper.net/Ggml/WhisperGgmlDownloader.cs), which is ~144mb at the time of writing. If you look at the source code in that link, you'll see that this downloader pulls whisper models from huggingface.co at [this url](https://huggingface.co/sandrohanea/whisper.net/tree/main/classic). To use any of the models on this page, simply download them to a folder and provide the full path in your pizzwave settings. It is well known that the latest "large" version of the model performs best but at a storage cost (~3.1gb). + +# Configuration + +`pizzalib` configuration lives in `\pizzawave\settings.json`. On Windows, this is `Users\\AppData\Roaming\pizzawave\settings.json`. This file can be manipulated in `pizzaui` and `pizzacmd`, but please see those tools' README file for details. The UI includes a feature that allows you to setup your configuration in a more automated way, but you can always create the file manually. If you run the UI or command line application without a settings file, the default one will be created in the location specified above. + +Supported settings/parameters: +* `TraceLevelApp` (default=`Error`): Controls the verbosity of trace logging: `All` = -1, `Off` = 0, `Critical` = 1, `Error` = 3, `Warning` = 7, `Information` = 15, `Verbose` = 31 +* `WavFileLocation` (default=Off): by default, the audio data streamed from trunk-recorder will only be transcribed in-memory; to save the audio to compressed MP3 files locally on the server side, provide a path here. Note that this can consume significant space over time. It is advisable to periodically clean out these folders. +* `Alerts`: an array of alert rules, with these parameters: + * `Id`: a GUID uniquely identifying the rule + * `Name`: a friendly name for the rule + * `Email`: comma-separated list of emails to receive the alert + * `Keywords`: comma-separated list of keywords that trigger the alert + * `Frequency`: how often the rule should be re-evaluated on incoming call data; `RealTime` = 0, `Hourly` = 1, `Daily` = 2 + * `Talkgroups`: an array of `long` IDs for talkgroups of interest on the trunked system being monitored + * `CaptureWAV`: should a WAV/MP3 be saved when this rule is triggered + * `Enabled`: set to `true` to enable, `false` to disable +* `Autostartlistener` (default = `true`): whether or not to automatically start `pizzalib` listener when the program or UI starts +* `gmailUser` and `gmailPassword`: to send alerts via email/email-to-SMS, you must provide an app password to your gmail account. Note that this is *not* the password for your gmail account, but rather an app-specific password. See the Alerting section below for further details. Note that the gmail app password specified here is stored _un-encrypted on-disk_. +* `listenPort` (default=9123): what port should the `pizzalib` server listen on; the trunk-recorder client should be configured to connect to this port +* `analogChannels` (default=1): the number of analog channels in the received audio WAV data +* `analogBitDepth` (default=16): the bit depth of the received audio WAV data +* `analogSamplingRate` (default=8000): the sampling rate of the received audio WAV data, in hertz +* `talkgroups`: an array of talkgroups, with these parameters (see the next section for guidance): + * `Id`: the decimal ID of the talkgroup + * `Mode`: the talkgroup mode (`D`: digital, `A`: analog, `M`: mixed, `T`: tdma-capable, `De`: digital/partial encryption, `DE`: digital/full encryption) + * `AlphaTag`: 16-character description intended as a shortened display on radios + * `Description`: custom description + * `Tag`: talkgroup official service tag + * `Category`: additional category, if available +* `whisperModelFile` - path to the whisper model file to use; leave this blank to use the default "base" model (144mb) or download a different one as mentioned above. + +## Talkgroups +A talkgroup is a concept specific to [P25 trunked radio systems](https://en.wikipedia.org/wiki/Trunked_radio_system) and refers to a logical grouping of users communicating on the trunked system. You will need to browse the [RR database](https://www.radioreference.com/db/browse/) to find information on trunk and traditional systems in your area. `pizzalib` needs to know about talkgroups in your area that you're interested in monitoring. Talkgroups are specified in the settings file discussed above. You might want to checkout the [trunk-recorder configuration how-to](https://trunkrecorder.com/docs/CONFIGURE#talkgroupsfile) and the talkgroups section of [this guide](https://www.andrewmohawk.com/2020/06/12/trunked-radio-a-guide/) if you're unsure about talkgroups or want to learn more. + +# Alerts + +Alerts are well-structured rules that tell `pizzalib` how to process audio data of interest. Read the sections below to find out more about Alerts. + +## How do I get an email alert? + +You need to [add an app password](https://support.google.com/accounts/answer/185833) to your gmail account and provide it in `pizzalib` settings. + +## How do I get a phone alert? + +You can send an alert to your phone by using your carrier's email-to-SMS service. As examples: +* ATT: @txt.att.net +* Verizon: @vtext.com +* Sprint: @messaging.sprintpcs.com +* T-Mobile: @tmomail.net + +## What keywords do I choose? +A good start is your street name or other locations near you. The next step is to consider 10-codes (e.g., "10-4" typically means "okay") and other police codes specific to your locale. You can find some codes [here](https://www.bearcat1.com/radio.htm), but this requires some work on your part. \ No newline at end of file diff --git a/pizzalib/Settings.cs b/pizzalib/Settings.cs new file mode 100644 index 0000000..bae8c28 --- /dev/null +++ b/pizzalib/Settings.cs @@ -0,0 +1,218 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; +using System.Reflection; +using Newtonsoft.Json; + +namespace pizzalib +{ + using static TraceLogger; + + public class Settings : IEquatable + { + public static readonly string DefaultWorkingDirectory = Path.Combine( + new string[] { Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "pizzawave"}); + public static string DefaultSettingsFileName = "settings.json"; + public static string DefaultCallLogFileName = "calls.json"; + public static string DefaultSettingsFileLocation = Path.Combine( + DefaultWorkingDirectory, DefaultSettingsFileName); + public static string DefaultAlertWavLocation = Path.Combine(DefaultWorkingDirectory, "alerts"); + // + // pizzalib library settings + // + public SourceLevels TraceLevelApp; + public string? WavFileLocation; + public List Alerts; + public bool AutostartListener; + public string? gmailUser; + public string? gmailPassword; + // + // TrunkRecorder settings + // + public int listenPort; + public int analogChannels; + public int analogBitDepth; + public int analogSamplingRate; + public List? talkgroups; + // + // whisper.net settings + // + public string? whisperModelFile; + // + // Non-serializable fields + // + [JsonIgnore] + public Action? UpdateProgressLabelCallback; + [JsonIgnore] + public Action? UpdateConnectionLabelCallback; + [JsonIgnore] + public Action? SetProgressBarCallback; + [JsonIgnore] + public Action? ProgressBarStepCallback; + [JsonIgnore] + public Action? HideProgressBarCallback; + + public Settings() + { + if (!Directory.Exists(DefaultWorkingDirectory)) + { + try + { + Directory.CreateDirectory(DefaultWorkingDirectory); + } + catch (Exception ex) + { + Trace(TraceLoggerType.Settings, + TraceEventType.Warning, + $"Unable to create settings directory " + + $"'{DefaultWorkingDirectory}': {ex.Message}"); + } + } + Alerts = new List(); + TraceLevelApp = SourceLevels.Error; + AutostartListener = true; + listenPort = 9123; + analogSamplingRate = 8000; + analogBitDepth = 16; + analogChannels = 1; + } + + public override bool Equals(object? Other) + { + if (Other == null) + { + return false; + } + var field = Other as Settings; + return Equals(field); + } + + public bool Equals(Settings? Other) + { + if (Other == null) + { + return false; + } + return TraceLevelApp == Other.TraceLevelApp && + WavFileLocation == Other.WavFileLocation && + Alerts == Other.Alerts && + AutostartListener == Other.AutostartListener && + gmailUser == Other.gmailUser && + gmailPassword == Other.gmailPassword && + listenPort == Other.listenPort && + analogChannels == Other.analogChannels && + analogBitDepth == Other.analogBitDepth && + analogSamplingRate == Other.analogSamplingRate && + talkgroups == Other.talkgroups && + whisperModelFile == Other.whisperModelFile; + } + + public static bool operator ==(Settings? Settings1, Settings? Settings2) + { + if ((object)Settings1 == null || (object)Settings2 == null) + return Equals(Settings1, Settings2); + return Settings1.Equals(Settings2); + } + + public static bool operator !=(Settings? Settings1, Settings? Settings2) + { + if ((object)Settings1 == null || (object)Settings2 == null) + return !Equals(Settings1, Settings2); + return !(Settings1.Equals(Settings2)); + } + + public override int GetHashCode() + { + return (TraceLevelApp, + WavFileLocation, + Alerts, + AutostartListener, + gmailUser, + gmailPassword, + listenPort, + analogBitDepth, + analogChannels, + analogSamplingRate, + talkgroups, + whisperModelFile + ).GetHashCode(); + } + + public static bool HasFieldChanged(Settings Object1, Settings Object2, string Name) + { + var fields = typeof(Settings).GetFields( + BindingFlags.Public | BindingFlags.Instance).ToList(); + var field = fields.FirstOrDefault(p => p.Name == Name); + try + { + dynamic value1 = field!.GetValue(Object1)!; + dynamic value2 = field!.GetValue(Object2)!; + return value1 != value2; + } + catch (Exception) { return false; } + } + + public virtual void Validate() + { + if (!string.IsNullOrEmpty(whisperModelFile) && + !File.Exists(whisperModelFile)) + { + throw new Exception($"Invalid whisper model file: {whisperModelFile}"); + } + + if (!string.IsNullOrEmpty(gmailUser)) + { + if (string.IsNullOrEmpty(gmailPassword)) + { + throw new Exception("Gmail password is required"); + } + } + else if (!string.IsNullOrEmpty(gmailPassword)) + { + if (string.IsNullOrEmpty(gmailUser)) + { + throw new Exception("Gmail user is required"); + } + } + } + + public void SaveToFile(string? Target) + { + var target = Target; + if (string.IsNullOrEmpty(target)) + { + target = Settings.DefaultSettingsFileLocation; + } + + try + { + Validate(); + var json = JsonConvert.SerializeObject(this, Formatting.Indented); + File.WriteAllText(target, json); + } + catch (Exception ex) + { + throw new Exception($"Could not save the Settings object " + + $"to JSON: {ex.Message}"); + } + } + } +} + diff --git a/pizzalib/StreamServer.cs b/pizzalib/StreamServer.cs new file mode 100644 index 0000000..675bb03 --- /dev/null +++ b/pizzalib/StreamServer.cs @@ -0,0 +1,133 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Net.Sockets; +using System.Net; +using System.Diagnostics; +using System.Text; +using static pizzalib.TraceLogger; + +namespace pizzalib +{ + public class StreamServer + { + private CancellationTokenSource CancelSource; + private Func NewCallDataCallback; + private Settings m_Settings; + + public StreamServer( + Func NewCallDataCallback_, + Settings Settings) + { + NewCallDataCallback = NewCallDataCallback_; + CancelSource = new CancellationTokenSource(); + m_Settings = Settings; + } + + public async Task Listen() + { + var ipEndPoint = new IPEndPoint(IPAddress.Any, m_Settings.listenPort); + TcpListener listener = new(ipEndPoint); + List tasks = new List(); + CancelSource = new CancellationTokenSource(); + + try + { + var listenStr = $"Listening on port {m_Settings.listenPort}"; + m_Settings.UpdateConnectionLabelCallback?.Invoke(listenStr); + Trace(TraceLoggerType.StreamServer, TraceEventType.Verbose, listenStr); + listener.Start(); + while (!CancelSource.IsCancellationRequested) + { + var client = await listener.AcceptTcpClientAsync(CancelSource.Token); + var task = Task.Run(() => HandleNewClient(client)).ContinueWith( + t => m_Settings.UpdateConnectionLabelCallback?.Invoke(listenStr)); + tasks.Add(task); + } + } + catch (AggregateException ae) + { + if (ae.InnerException != null && ae.InnerException is OperationCanceledException) + { + Trace(TraceLoggerType.StreamServer, + TraceEventType.Verbose, + $"Successfully canceled listener operation."); + return true; + } + var str = new StringBuilder(); + str.AppendLine(ae.Message); + if (ae.InnerException != null && !string.IsNullOrEmpty(ae.InnerException.Message)) + { + str.AppendLine(ae.InnerException.Message); + } + Trace(TraceLoggerType.Settings, + TraceEventType.Error, + $"Caught aggregate exception: {str}"); + } + catch (OperationCanceledException) + { + Trace(TraceLoggerType.StreamServer, + TraceEventType.Verbose, + $"Successfully canceled listener operation."); + return true; + } + finally + { + Task.WaitAll(tasks.ToArray()); + listener.Stop(); + } + return true; + } + + public void Shutdown() + { + Trace(TraceLoggerType.StreamServer, + TraceEventType.Verbose, + $"Received shutdown request."); + CancelSource?.CancelAsync(); + } + + private async Task HandleNewClient(TcpClient Client) + { + var clientEndpoint = Client.Client.RemoteEndPoint as IPEndPoint; + var clientStr = $"{clientEndpoint!.Address}:{clientEndpoint!.Port}"; + Trace(TraceLoggerType.StreamServer, + TraceEventType.Verbose, + $"Receiving from {clientStr}"); + m_Settings.UpdateConnectionLabelCallback?.Invoke($"Receiving from {clientStr}"); + try + { + using (var stream = Client.GetStream()) + { + var wavStream = new WavStreamData(m_Settings); + var result = await wavStream.ProcessClientData(stream, CancelSource); + if (result) + { + _ = NewCallDataCallback(wavStream); // blocking + } + } + } + catch (Exception ex) + { + Trace(TraceLoggerType.StreamServer, + TraceEventType.Error, + $"HandleNewClient() exception: {ex.Message}"); + } + } + } +} diff --git a/pizzalib/Talkgroup.cs b/pizzalib/Talkgroup.cs new file mode 100644 index 0000000..f81798d --- /dev/null +++ b/pizzalib/Talkgroup.cs @@ -0,0 +1,129 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using CsvHelper; +using CsvHelper.Configuration.Attributes; +using System.Globalization; + +namespace pizzalib +{ + public class Talkgroup : IEquatable, IComparable + { + [Index(0)] + public long Id { get; set; } + [Index(1)] + public string Mode { get; set; } + [Index(2)] + public string AlphaTag { get; set; } + [Index(3)] + public string Description { get; set; } + [Index(4)] + public string Tag { get; set; } + [Index(5)] + public string Category { get; set; } + + public int CompareTo(Talkgroup obj) + { + // + // Sorting Talkgroup objects is by Id + // + return Id.CompareTo(obj.Id); + } + + public override string ToString() + { + return $"{AlphaTag} ({Tag}/{Category}) - {Description}"; + } + + public override bool Equals(object? Other) + { + if (Other == null) + { + return false; + } + var field = Other as Talkgroup; + return Equals(field); + } + + public bool Equals(Talkgroup? Other) + { + if (Other == null) + { + return false; + } + return Id == Other.Id && + Mode == Other.Mode && + AlphaTag == Other.AlphaTag && + Description == Other.Description && + Tag == Other.Tag && + Category == Other.Category; + } + + public static bool operator ==(Talkgroup? Talkgroup1, Talkgroup? Talkgroup2) + { + if ((object)Talkgroup1 == null || (object)Talkgroup2 == null) + return Equals(Talkgroup1, Talkgroup2); + return Talkgroup1.Equals(Talkgroup2); + } + + public static bool operator !=(Talkgroup? Talkgroup1, Talkgroup? Talkgroup2) + { + if ((object)Talkgroup1 == null || (object)Talkgroup2 == null) + return !Equals(Talkgroup1, Talkgroup2); + return !(Talkgroup1.Equals(Talkgroup2)); + } + + public override int GetHashCode() + { + return (Id, Mode, AlphaTag, Description, Tag, Category + ).GetHashCode(); + } + } + + public static class TalkgroupHelper + { + public static List GetTalkgroupsFromCsv(string FileName) + { + using (var reader = new StreamReader(FileName)) + using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture)) + { + return csv.GetRecords().ToList(); + } + } + + public static Talkgroup? LookupTalkgroup(Settings Settings, long Talkgroup) + { + if (Settings.talkgroups == null || Settings.talkgroups.Count == 0) + { + return null; + } + return Settings.talkgroups.FirstOrDefault(t => t.Id == Talkgroup); + } + + public static string FormatTalkgroup(Settings Settings, long Talkgroup, bool ShortFormat = false) + { + var talkgroup = LookupTalkgroup(Settings, Talkgroup); + if (talkgroup == null) + { + return $"{Talkgroup}"; + } + + return ShortFormat ? $"{talkgroup.AlphaTag}" : $"{talkgroup}"; + } + } +} diff --git a/pizzalib/TraceLogger.cs b/pizzalib/TraceLogger.cs new file mode 100644 index 0000000..5d793c4 --- /dev/null +++ b/pizzalib/TraceLogger.cs @@ -0,0 +1,177 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; + +namespace pizzalib +{ + public static class TraceLogger + { + public static readonly string m_TraceFileDir = Path.Combine(new string[] { + Settings.DefaultWorkingDirectory, "Logs"}); + private static string m_Location = Path.Combine(new string[] { m_TraceFileDir, + $"pizzawave-{DateTime.Now.ToString("yyyy-MM-dd-HHmmss")}.txt"}); + private static TextWriterTraceListener m_TextWriterTraceListener = + new TextWriterTraceListener(m_Location, "pizzawaveTextWriterListener"); + private static ConsoleTraceListener m_ConsoleTraceListener = new ConsoleTraceListener(); + private static SourceSwitch m_Switch = + new SourceSwitch("pizzawaveSwitch", "Verbose"); + private static TraceSource[] Sources = { + new TraceSource("MainWindow", SourceLevels.Verbose), + new TraceSource("StreamServer", SourceLevels.Verbose), + new TraceSource("WavStreamData", SourceLevels.Verbose), + new TraceSource("Settings", SourceLevels.Verbose), + new TraceSource("Whisper", SourceLevels.Verbose), + new TraceSource("Alerts", SourceLevels.Verbose), + new TraceSource("Utilities", SourceLevels.Verbose), + new TraceSource("Headless", SourceLevels.Verbose), + }; + + public enum TraceLoggerType + { + MainWindow, + StreamServer, + WavStreamData, + Settings, + Whisper, + Alerts, + Utilities, + Headless, + Max + } + + public static void Initialize(bool RedirectToStdout = false) + { + System.Diagnostics.Trace.AutoFlush = true; + foreach (var source in Sources) + { + source.Listeners.Add(m_TextWriterTraceListener); + source.Switch = m_Switch; + if (RedirectToStdout) + { + source.Listeners.Add(m_ConsoleTraceListener); + } + } + + if (Directory.Exists(Settings.DefaultWorkingDirectory)) + { + if (!Directory.Exists(m_TraceFileDir)) + { + try + { + Directory.CreateDirectory(m_TraceFileDir); + } + catch (Exception) // swallow + { + } + } + } + } + + public static void Shutdown() + { + m_TextWriterTraceListener.Close(); + m_ConsoleTraceListener.Close(); + } + + public static void SetLevel(SourceLevels Level) + { + m_Switch.Level = Level; + } + + public static void Trace(TraceLoggerType Type, TraceEventType EventType, string Message) + { + if (Type >= TraceLoggerType.Max) + { + throw new Exception("Invalid logger type"); + } + using (GetColorContext(EventType)) + { + Sources[(int)Type].TraceEvent(EventType, 1, $"{DateTime.Now:M/d/yyyy h:mm tt}: {Message}"); + } + } + + public static void OpenTraceLog() + { + Utilities.LaunchFile(m_Location); + } + + private static ColorContext GetColorContext(TraceEventType eventType) + { + switch (eventType) + { + case TraceEventType.Verbose: + return new ColorContext(ConsoleColor.DarkGray); + case TraceEventType.Information: + return new ColorContext(ConsoleColor.Gray); + case TraceEventType.Critical: + return new ColorContext(ConsoleColor.DarkRed); + case TraceEventType.Error: + return new ColorContext(ConsoleColor.Red); + case TraceEventType.Warning: + return new ColorContext(ConsoleColor.Yellow); + case TraceEventType.Start: + return new ColorContext(ConsoleColor.DarkGreen); + case TraceEventType.Stop: + return new ColorContext(ConsoleColor.DarkMagenta); + case TraceEventType.Transfer: + return new ColorContext(ConsoleColor.DarkYellow); + default: + return new ColorContext(); + } + } + } + + internal sealed class ColorContext : IDisposable + { + private readonly ConsoleColor previousBackgroundColor; + private readonly ConsoleColor previousForegroundColor; + private bool isDisposed; + + public ColorContext() + : this(Console.ForegroundColor, Console.BackgroundColor) + { + } + + public ColorContext(ConsoleColor foregroundColor) + : this(foregroundColor, Console.BackgroundColor) + { + } + + public ColorContext(ConsoleColor foregroundColor, ConsoleColor backgroundColor) + { + this.isDisposed = false; + this.previousForegroundColor = Console.ForegroundColor; + this.previousBackgroundColor = Console.BackgroundColor; + Console.ForegroundColor = foregroundColor; + Console.BackgroundColor = backgroundColor; + } + + public void Dispose() + { + if (this.isDisposed) + { + return; + } + + Console.ForegroundColor = this.previousForegroundColor; + Console.BackgroundColor = this.previousBackgroundColor; + this.isDisposed = true; + } + } +} diff --git a/pizzalib/TranscribedCall.cs b/pizzalib/TranscribedCall.cs new file mode 100644 index 0000000..0058ad7 --- /dev/null +++ b/pizzalib/TranscribedCall.cs @@ -0,0 +1,51 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Text.Json.Serialization; + +namespace pizzalib +{ + public class TranscribedCall + { + public long StartTime { get; set; } + public long StopTime { get; set; } + public int Source { get; set; } + public string SystemShortName { get; set; } + public long CallId { get; set; } + public List PatchedTalkgroups { get; set; } + public long Talkgroup { get; set; } + public double Frequency { get; set; } + public string Location { get; set; } + public string Transcription { get; set; } + // + // Used to track when the call is being played in the UI + // + [JsonIgnore] + public bool IsAudioPlaying; + public Guid UniqueId; + public bool IsAlertMatch; + + public string ToString(Settings Settings) + { + var talkgroup = TalkgroupHelper.FormatTalkgroup(Settings, Talkgroup); + DateTime date = DateTimeOffset.FromUnixTimeSeconds(StartTime).ToLocalTime().DateTime; + var dateStr = $"{date:M/d/yyyy h:mm tt}"; + return $"[{talkgroup}]:{dateStr}: {Transcription}"; + } + } +} diff --git a/pizzalib/Utilities.cs b/pizzalib/Utilities.cs new file mode 100644 index 0000000..eb07b6f --- /dev/null +++ b/pizzalib/Utilities.cs @@ -0,0 +1,38 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; + +namespace pizzalib +{ + public static class Utilities + { + + public static void LaunchFile(string FileName) + { + if (!File.Exists(FileName)) + { + return; + } + var psi = new ProcessStartInfo(); + psi.FileName = FileName; + psi.UseShellExecute = true; + Process.Start(psi); + } + } +} diff --git a/pizzalib/WavStreamData.cs b/pizzalib/WavStreamData.cs new file mode 100644 index 0000000..9684815 --- /dev/null +++ b/pizzalib/WavStreamData.cs @@ -0,0 +1,258 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ + +using System.Diagnostics; +using System.Net.Sockets; +using NAudio.Utils; +using NAudio.Wave.SampleProviders; +using NAudio.Wave; +using Newtonsoft.Json.Linq; +using System.Text; + +namespace pizzalib +{ + using static pizzalib.TraceLogger; + + public enum OutputFileFormat + { + Wav, + Mp3 + } + + public class WavStreamData + { + private readonly static int PIZZA_MAGIC = 0x415A5A50; // pzza + private readonly static int MAX_JSON_LENGTH = 4096 * 2; + private readonly static int MAX_SAMPLE_COUNT = 0xfffffe; + private MemoryStream m_WavData; + private MemoryStream m_JsonData; + private Settings m_Settings; + + public WavStreamData(Settings settings) + { + m_WavData = new MemoryStream(); + m_JsonData = new MemoryStream(); + m_Settings = settings; + } + + public async Task ProcessClientData(NetworkStream ClientStream, CancellationTokenSource CancelSource) + { + byte[] buffer4 = new byte[4]; + byte[] buffer8 = new byte[8]; + // + // Data format (from trunk-recorder/plugins/callstream): + // 4-byte magic header + // 8-byte JSON string length + // 4-byte sample count + // [json data - string] + // [sample data - array of int16s] + // + await ClientStream.ReadExactlyAsync(buffer4, 0, buffer4.Length, CancelSource.Token); + var magic = BitConverter.ToInt32(buffer4, 0); + if (magic != PIZZA_MAGIC) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"Got bad pizza magic: 0x{magic:X}!"); + return false; + } + await ClientStream.ReadExactlyAsync(buffer8, 0, buffer8.Length, CancelSource.Token); + var jsonLength = BitConverter.ToInt64(buffer8); + if (jsonLength > MAX_JSON_LENGTH) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"Got bad JSON length {jsonLength}"); + return false; + } + await ClientStream.ReadExactlyAsync(buffer4, 0, buffer4.Length, CancelSource.Token); + var sampleCount = BitConverter.ToInt32(buffer4, 0); + if (sampleCount > MAX_SAMPLE_COUNT) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"Got bad sample count {sampleCount}"); + return false; + } + + // + // Read in JSON data + // + byte[] dataBuffer = new byte[jsonLength]; + var bytesRead = await ClientStream.ReadAtLeastAsync(dataBuffer, dataBuffer.Length, true, CancelSource.Token); + if (bytesRead != jsonLength) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"Received incomplete JSON data: expected {jsonLength} but got {bytesRead}"); + return false; + } + m_JsonData.Write(dataBuffer, 0, bytesRead); + + // + // Read in sample data + // + var expectedSampleSize = sampleCount * sizeof(ushort); + dataBuffer = new byte[expectedSampleSize]; + bytesRead = await ClientStream.ReadAtLeastAsync(dataBuffer, dataBuffer.Length, true, CancelSource.Token); + if (bytesRead != expectedSampleSize) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"Received incomplete sample data: expected {expectedSampleSize} but got {bytesRead}"); + return false; + } + + // + // Create a WAV memorystream from the sample data. + // + try + { + m_WavData = GetWavStream(dataBuffer); + } + catch (Exception ex) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"Failed to create WAV stream from sample data: {ex.Message}"); + return false; + } + + if (m_WavData.Length == 0) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"WavWriter produced an empty WAV stream"); + return false; + } + + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Information, + $"Received data: {m_JsonData.Length} bytes JSON / {m_WavData.Length} bytes samples."); + return true; + } + + public MemoryStream GetRawStream() + { + return m_WavData; + } + public JObject GetJsonObject() + { + var json = Encoding.UTF8.GetString(m_JsonData.GetBuffer()); + return JObject.Parse(json); + } + + public void DumpStreamToFile(string BaseDir, string FileName, OutputFileFormat Format) + { + if (string.IsNullOrEmpty(BaseDir)) + { + throw new Exception("No output location specified"); + } + + if (!Directory.Exists(BaseDir)) + { + try + { + Directory.CreateDirectory(BaseDir); + } + catch (Exception ex) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"Unable to create output directory '{BaseDir}': {ex.Message}"); + throw; + } + } + + var target = Path.Combine(BaseDir, FileName); + try + { + switch (Format) + { + case OutputFileFormat.Wav: + { + File.WriteAllBytes(target, m_WavData.GetBuffer()); + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Information, + $"ProcessAudioData: Wrote WAV data to {target}"); + break; + } + case OutputFileFormat.Mp3: + { + // + // If the specified TR sample rate was less than 16khz, it was resampled. + // + var sampleRate = Math.Max(m_Settings.analogSamplingRate, 16000); + var wavFormat = new WaveFormat( + sampleRate, + m_Settings.analogBitDepth, + m_Settings.analogChannels); + using (var reader = new WaveFileReader(m_WavData)) + { + MediaFoundationEncoder.EncodeToMp3(reader, target); + } + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Information, + $"ProcessAudioData: Wrote MP3 data to {target}"); + break; + } + default: + { + throw new Exception("Unsupported output format"); + } + } + } + catch (Exception ex) + { + Trace(TraceLoggerType.WavStreamData, + TraceEventType.Error, + $"Failed to write stream data to {target}: {ex.Message}"); + return; + } + finally + { + m_WavData.Seek(0, SeekOrigin.Begin); + } + } + + private MemoryStream GetWavStream(byte[] SampleData) + { + MemoryStream wavStream = new MemoryStream(); + var format = new WaveFormat(m_Settings.analogSamplingRate, + m_Settings.analogBitDepth, m_Settings.analogChannels); + using (var wavWriter = new WaveFileWriter(new IgnoreDisposeStream(wavStream), format)) + { + wavWriter.Write(SampleData, 0, SampleData.Length); + } + wavStream.Seek(0, SeekOrigin.Begin); + if (format.SampleRate < 16000) // upconversion required by whisper + { + using (var reader = new WaveFileReader(wavStream)) + { + var resampler = new WdlResamplingSampleProvider(reader.ToSampleProvider(), 16000); + MemoryStream wavStream2 = new MemoryStream(); + WaveFileWriter.WriteWavFileToStream(wavStream2, resampler.ToWaveProvider16()); + wavStream2.Seek(0, SeekOrigin.Begin); + return wavStream2; + } + } + return wavStream; + } + } +} diff --git a/pizzalib/Whisper.cs b/pizzalib/Whisper.cs new file mode 100644 index 0000000..7e238c0 --- /dev/null +++ b/pizzalib/Whisper.cs @@ -0,0 +1,164 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; +using System.Text; +using Whisper.net.Ggml; +using Whisper.net; + +namespace pizzalib +{ + using static TraceLogger; + + public class Whisper + { + private string m_ModelFile; + private readonly string s_ModelFolder = Path.Combine( + Settings.DefaultWorkingDirectory, "model"); + private bool m_Initialized; + private Settings m_Settings; + + public Whisper(Settings Settings) + { + m_Initialized = false; + m_ModelFile = string.Empty; + m_Settings = Settings; + } + + public async Task Initialize() + { + m_Settings.UpdateProgressLabelCallback?.Invoke("Initializing Whisper model..."); + + if (!string.IsNullOrEmpty(m_Settings.whisperModelFile)) + { + m_ModelFile = m_Settings.whisperModelFile; // nothing to do + } + else + { + if (!Directory.Exists(s_ModelFolder)) + { + try + { + Directory.CreateDirectory(s_ModelFolder); + } + catch (Exception ex) + { + Trace(TraceLoggerType.Whisper, + TraceEventType.Error, + $"Unable to create model directory " + + $"'{s_ModelFolder}': {ex.Message}"); + throw; + } + } + + m_ModelFile = Path.Combine(s_ModelFolder, "ggml-base.bin"); + + if (!File.Exists(m_ModelFile)) + { + m_Settings.UpdateProgressLabelCallback?.Invoke("Downloading Whisper model..."); + m_Settings.SetProgressBarCallback?.Invoke(2, 1); + m_Settings.ProgressBarStepCallback?.Invoke(); + Trace(TraceLoggerType.Whisper, + TraceEventType.Information, + $"Downloading model file to {m_ModelFile}"); + try + { + var modelStream = await WhisperGgmlDownloader.GetGgmlModelAsync(GgmlType.Base); + var fileWriter = File.OpenWrite(m_ModelFile); + await modelStream.CopyToAsync(fileWriter); + } + catch (Exception ex) + { + Trace(TraceLoggerType.Whisper, + TraceEventType.Error, + $"Failed to download model {m_ModelFile}: {ex.Message}"); + throw; + } + m_Settings.ProgressBarStepCallback?.Invoke(); + } + } + m_Initialized = true; + m_Settings.UpdateProgressLabelCallback?.Invoke("Whisper initialized."); + return true; + } + + public async Task TranscribeCall(WavStreamData CallData) + { + if (!m_Initialized) + { + throw new Exception("Whisper model is not initialized."); + } + + var call = new TranscribedCall(); + call.UniqueId = Guid.NewGuid(); + + try + { + var jsonObject = CallData.GetJsonObject(); + call.StopTime = jsonObject["StopTime"]!.ToObject(); + call.StartTime = jsonObject["StartTime"]!.ToObject(); + call.CallId = jsonObject["CallId"]!.ToObject(); + call.Source = jsonObject["Source"]!.ToObject(); + call.Talkgroup = jsonObject["Talkgroup"]!.ToObject(); + call.PatchedTalkgroups = jsonObject["PatchedTalkgroups"]!.ToObject>(); + call.Frequency = jsonObject["Frequency"]!.ToObject(); + call.SystemShortName = jsonObject["SystemShortName"]!.ToObject(); + } + catch (Exception ex) + { + var err = $"Unable to parse JSON data: {ex.Message}"; + Trace(TraceLoggerType.Whisper, TraceEventType.Error, err); + throw new Exception(err); + } + + try + { + using (var whisperFactory = WhisperFactory.FromPath(m_ModelFile)) + { + var processor = whisperFactory.CreateBuilder().WithLanguage("auto").Build(); + var sb = new StringBuilder(); + await foreach (var result in processor.ProcessAsync(CallData.GetRawStream())) + { + sb.Append($"{result.Text} "); + } + call.Transcription = sb.ToString(); + } + } + catch (Exception ex) + { + var err = $"Failed to transcribe WAV data: {ex.Message}"; + Trace(TraceLoggerType.Whisper, TraceEventType.Error, err); + throw new Exception(err); + } + + if (string.IsNullOrEmpty(call.Transcription)) + { + var err = $"Transcription was empty"; + Trace(TraceLoggerType.Whisper, TraceEventType.Error, err); + throw new Exception(err); + } + + var length = Math.Min(25, call.Transcription.Length); + var snippet = call.Transcription.Substring(0, length); + Trace(TraceLoggerType.Whisper, + TraceEventType.Information, + $"Transcribed call id={call.CallId}: \"{snippet}...\""); + return call; + } + } +} diff --git a/pizzalib/pizzalib.csproj b/pizzalib/pizzalib.csproj new file mode 100644 index 0000000..926b6de --- /dev/null +++ b/pizzalib/pizzalib.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/pizzaui/AlertManagerWindow.Designer.cs b/pizzaui/AlertManagerWindow.Designer.cs new file mode 100644 index 0000000..9e5b65b --- /dev/null +++ b/pizzaui/AlertManagerWindow.Designer.cs @@ -0,0 +1,400 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +namespace pizzaui +{ + public partial class AlertManagerWindow + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(AlertManagerWindow)); + alertsTabControl = new TabControl(); + tabPage3 = new TabPage(); + splitContainer1 = new SplitContainer(); + enableCheckbox = new CheckBox(); + captureWavCheckbox = new CheckBox(); + talkgroupsTextbox = new TextBox(); + label6 = new Label(); + alertNameTextbox = new TextBox(); + addButton = new Button(); + removeButton = new Button(); + alertsListview = new BrightIdeasSoftware.FastObjectListView(); + label5 = new Label(); + label4 = new Label(); + alertFrequencyCombobox = new ComboBox(); + label3 = new Label(); + label2 = new Label(); + alertEmailTextbox = new TextBox(); + applyToSelectedRadioButton = new RadioButton(); + applyToAllRadioButton = new RadioButton(); + label1 = new Label(); + alertKeywordsTextbox = new TextBox(); + label9 = new Label(); + talkgroupsListview = new BrightIdeasSoftware.FastObjectListView(); + alertsTabControl.SuspendLayout(); + tabPage3.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit(); + splitContainer1.Panel1.SuspendLayout(); + splitContainer1.Panel2.SuspendLayout(); + splitContainer1.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)alertsListview).BeginInit(); + ((System.ComponentModel.ISupportInitialize)talkgroupsListview).BeginInit(); + SuspendLayout(); + // + // alertsTabControl + // + alertsTabControl.Controls.Add(tabPage3); + alertsTabControl.Dock = DockStyle.Fill; + alertsTabControl.Location = new Point(0, 0); + alertsTabControl.Name = "alertsTabControl"; + alertsTabControl.SelectedIndex = 0; + alertsTabControl.Size = new Size(1832, 912); + alertsTabControl.TabIndex = 10; + // + // tabPage3 + // + tabPage3.Controls.Add(splitContainer1); + tabPage3.Location = new Point(4, 34); + tabPage3.Name = "tabPage3"; + tabPage3.Size = new Size(1824, 874); + tabPage3.TabIndex = 2; + tabPage3.Text = "Alerts"; + tabPage3.UseVisualStyleBackColor = true; + // + // splitContainer1 + // + splitContainer1.Dock = DockStyle.Fill; + splitContainer1.Location = new Point(0, 0); + splitContainer1.Name = "splitContainer1"; + // + // splitContainer1.Panel1 + // + splitContainer1.Panel1.Controls.Add(enableCheckbox); + splitContainer1.Panel1.Controls.Add(captureWavCheckbox); + splitContainer1.Panel1.Controls.Add(talkgroupsTextbox); + splitContainer1.Panel1.Controls.Add(label6); + splitContainer1.Panel1.Controls.Add(alertNameTextbox); + splitContainer1.Panel1.Controls.Add(addButton); + splitContainer1.Panel1.Controls.Add(removeButton); + splitContainer1.Panel1.Controls.Add(alertsListview); + splitContainer1.Panel1.Controls.Add(label5); + splitContainer1.Panel1.Controls.Add(label4); + splitContainer1.Panel1.Controls.Add(alertFrequencyCombobox); + splitContainer1.Panel1.Controls.Add(label3); + splitContainer1.Panel1.Controls.Add(label2); + splitContainer1.Panel1.Controls.Add(alertEmailTextbox); + splitContainer1.Panel1.Controls.Add(applyToSelectedRadioButton); + splitContainer1.Panel1.Controls.Add(applyToAllRadioButton); + splitContainer1.Panel1.Controls.Add(label1); + splitContainer1.Panel1.Controls.Add(alertKeywordsTextbox); + splitContainer1.Panel1.Controls.Add(label9); + // + // splitContainer1.Panel2 + // + splitContainer1.Panel2.Controls.Add(talkgroupsListview); + splitContainer1.Size = new Size(1824, 874); + splitContainer1.SplitterDistance = 890; + splitContainer1.TabIndex = 26; + // + // enableCheckbox + // + enableCheckbox.AutoSize = true; + enableCheckbox.Checked = true; + enableCheckbox.CheckState = CheckState.Checked; + enableCheckbox.Location = new Point(493, 339); + enableCheckbox.Name = "enableCheckbox"; + enableCheckbox.Size = new Size(101, 29); + enableCheckbox.TabIndex = 45; + enableCheckbox.Text = "Enabled"; + enableCheckbox.UseVisualStyleBackColor = true; + // + // captureWavCheckbox + // + captureWavCheckbox.AutoSize = true; + captureWavCheckbox.Checked = true; + captureWavCheckbox.CheckState = CheckState.Checked; + captureWavCheckbox.Location = new Point(493, 304); + captureWavCheckbox.Name = "captureWavCheckbox"; + captureWavCheckbox.Size = new Size(143, 29); + captureWavCheckbox.TabIndex = 44; + captureWavCheckbox.Text = "Capture WAV"; + captureWavCheckbox.UseVisualStyleBackColor = true; + // + // talkgroupsTextbox + // + talkgroupsTextbox.Location = new Point(296, 24); + talkgroupsTextbox.Name = "talkgroupsTextbox"; + talkgroupsTextbox.Size = new Size(573, 31); + talkgroupsTextbox.TabIndex = 1; + // + // label6 + // + label6.AutoSize = true; + label6.Location = new Point(19, 82); + label6.Margin = new Padding(4, 0, 4, 0); + label6.Name = "label6"; + label6.Size = new Size(63, 25); + label6.TabIndex = 43; + label6.Text = "Name:"; + // + // alertNameTextbox + // + alertNameTextbox.Location = new Point(124, 76); + alertNameTextbox.Name = "alertNameTextbox"; + alertNameTextbox.Size = new Size(274, 31); + alertNameTextbox.TabIndex = 2; + // + // addButton + // + addButton.Location = new Point(643, 304); + addButton.Margin = new Padding(4, 5, 4, 5); + addButton.Name = "addButton"; + addButton.Size = new Size(108, 46); + addButton.TabIndex = 6; + addButton.Text = "Add"; + addButton.UseVisualStyleBackColor = true; + addButton.Click += addButton_Click; + // + // removeButton + // + removeButton.Location = new Point(759, 304); + removeButton.Margin = new Padding(4, 5, 4, 5); + removeButton.Name = "removeButton"; + removeButton.Size = new Size(108, 46); + removeButton.TabIndex = 40; + removeButton.Text = "Remove"; + removeButton.UseVisualStyleBackColor = true; + removeButton.Click += removeButton_Click; + // + // alertsListview + // + alertsListview.Dock = DockStyle.Bottom; + alertsListview.FullRowSelect = true; + alertsListview.GridLines = true; + alertsListview.Location = new Point(0, 386); + alertsListview.Name = "alertsListview"; + alertsListview.ShowGroups = false; + alertsListview.Size = new Size(890, 488); + alertsListview.TabIndex = 39; + alertsListview.View = View.Details; + alertsListview.VirtualMode = true; + alertsListview.SelectionChanged += alertsListview_SelectionChanged; + // + // label5 + // + label5.AutoSize = true; + label5.Font = new Font("Segoe UI", 9F, FontStyle.Italic); + label5.Location = new Point(28, 187); + label5.Margin = new Padding(4, 0, 4, 0); + label5.Name = "label5"; + label5.Size = new Size(89, 25); + label5.TabIndex = 37; + label5.Text = "separated"; + // + // label4 + // + label4.AutoSize = true; + label4.Font = new Font("Segoe UI", 9F, FontStyle.Italic); + label4.Location = new Point(28, 162); + label4.Margin = new Padding(4, 0, 4, 0); + label4.Name = "label4"; + label4.Size = new Size(77, 25); + label4.TabIndex = 36; + label4.Text = "comma-"; + // + // alertFrequencyCombobox + // + alertFrequencyCombobox.DropDownStyle = ComboBoxStyle.DropDownList; + alertFrequencyCombobox.FormattingEnabled = true; + alertFrequencyCombobox.Items.AddRange(new object[] { "RealTime", "Hourly", "Daily" }); + alertFrequencyCombobox.Location = new Point(124, 312); + alertFrequencyCombobox.Margin = new Padding(4, 5, 4, 5); + alertFrequencyCombobox.Name = "alertFrequencyCombobox"; + alertFrequencyCombobox.Size = new Size(274, 33); + alertFrequencyCombobox.TabIndex = 5; + // + // label3 + // + label3.AutoSize = true; + label3.Location = new Point(19, 317); + label3.Margin = new Padding(4, 0, 4, 0); + label3.Name = "label3"; + label3.Size = new Size(97, 25); + label3.TabIndex = 34; + label3.Text = "Frequency:"; + // + // label2 + // + label2.AutoSize = true; + label2.Location = new Point(442, 79); + label2.Margin = new Padding(4, 0, 4, 0); + label2.Name = "label2"; + label2.Size = new Size(58, 25); + label2.TabIndex = 33; + label2.Text = "Email:"; + // + // alertEmailTextbox + // + alertEmailTextbox.Location = new Point(526, 76); + alertEmailTextbox.Name = "alertEmailTextbox"; + alertEmailTextbox.Size = new Size(343, 31); + alertEmailTextbox.TabIndex = 3; + // + // applyToSelectedRadioButton + // + applyToSelectedRadioButton.AutoSize = true; + applyToSelectedRadioButton.Location = new Point(187, 27); + applyToSelectedRadioButton.Name = "applyToSelectedRadioButton"; + applyToSelectedRadioButton.Size = new Size(103, 29); + applyToSelectedRadioButton.TabIndex = 31; + applyToSelectedRadioButton.Text = "Selected"; + applyToSelectedRadioButton.UseVisualStyleBackColor = true; + applyToSelectedRadioButton.CheckedChanged += applyToSelectedRadioButton_CheckedChanged; + // + // applyToAllRadioButton + // + applyToAllRadioButton.AutoSize = true; + applyToAllRadioButton.Checked = true; + applyToAllRadioButton.Location = new Point(124, 27); + applyToAllRadioButton.Name = "applyToAllRadioButton"; + applyToAllRadioButton.Size = new Size(57, 29); + applyToAllRadioButton.TabIndex = 30; + applyToAllRadioButton.TabStop = true; + applyToAllRadioButton.Text = "All"; + applyToAllRadioButton.UseVisualStyleBackColor = true; + applyToAllRadioButton.CheckedChanged += applyToAllRadioButton_CheckedChanged; + // + // label1 + // + label1.AutoSize = true; + label1.Location = new Point(19, 128); + label1.Margin = new Padding(4, 0, 4, 0); + label1.Name = "label1"; + label1.Size = new Size(103, 25); + label1.TabIndex = 29; + label1.Text = "Keyword(s):"; + // + // alertKeywordsTextbox + // + alertKeywordsTextbox.Location = new Point(124, 125); + alertKeywordsTextbox.Multiline = true; + alertKeywordsTextbox.Name = "alertKeywordsTextbox"; + alertKeywordsTextbox.Size = new Size(745, 170); + alertKeywordsTextbox.TabIndex = 4; + // + // label9 + // + label9.AutoSize = true; + label9.Location = new Point(19, 27); + label9.Margin = new Padding(4, 0, 4, 0); + label9.Name = "label9"; + label9.Size = new Size(102, 25); + label9.TabIndex = 27; + label9.Text = "Talkgroups:"; + // + // talkgroupsListview + // + talkgroupsListview.Dock = DockStyle.Fill; + talkgroupsListview.FullRowSelect = true; + talkgroupsListview.GridLines = true; + talkgroupsListview.Location = new Point(0, 0); + talkgroupsListview.Name = "talkgroupsListview"; + talkgroupsListview.ShowGroups = false; + talkgroupsListview.Size = new Size(930, 874); + talkgroupsListview.TabIndex = 0; + talkgroupsListview.View = View.Details; + talkgroupsListview.VirtualMode = true; + talkgroupsListview.SelectionChanged += talkgroupsListview_SelectionChanged; + // + // AlertManagerWindow + // + AutoScaleDimensions = new SizeF(10F, 25F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(1832, 912); + Controls.Add(alertsTabControl); + DoubleBuffered = true; + Icon = (Icon)resources.GetObject("$this.Icon"); + Margin = new Padding(4, 5, 4, 5); + Name = "AlertManagerWindow"; + StartPosition = FormStartPosition.CenterParent; + Text = "Alert Manager"; + FormClosing += AlertManagerWindow_FormClosing; + Shown += AlertManagerWindow_Shown; + alertsTabControl.ResumeLayout(false); + tabPage3.ResumeLayout(false); + splitContainer1.Panel1.ResumeLayout(false); + splitContainer1.Panel1.PerformLayout(); + splitContainer1.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)splitContainer1).EndInit(); + splitContainer1.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)alertsListview).EndInit(); + ((System.ComponentModel.ISupportInitialize)talkgroupsListview).EndInit(); + ResumeLayout(false); + } + + #endregion + + private TabControl alertsTabControl; + private TabPage tabPage3; + private SplitContainer splitContainer1; + private Label label6; + private TextBox alertNameTextbox; + private Button addButton; + private Button removeButton; + private BrightIdeasSoftware.FastObjectListView alertsListview; + private Label label5; + private Label label4; + private ComboBox alertFrequencyCombobox; + private Label label3; + private Label label2; + private TextBox alertEmailTextbox; + private RadioButton applyToSelectedRadioButton; + private RadioButton applyToAllRadioButton; + private Label label1; + private TextBox alertKeywordsTextbox; + private Label label9; + private BrightIdeasSoftware.FastObjectListView talkgroupsListview; + private TextBox talkgroupsTextbox; + private CheckBox captureWavCheckbox; + private CheckBox enableCheckbox; + } +} \ No newline at end of file diff --git a/pizzaui/AlertManagerWindow.cs b/pizzaui/AlertManagerWindow.cs new file mode 100644 index 0000000..e2a311c --- /dev/null +++ b/pizzaui/AlertManagerWindow.cs @@ -0,0 +1,328 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using BrightIdeasSoftware; +using pizzalib; + +namespace pizzaui +{ + public partial class AlertManagerWindow : Form + { + private bool m_SaveDisabled; + private Settings m_Settings; + + public AlertManagerWindow(Settings Settings, bool SaveDisabled) + { + InitializeComponent(); + m_Settings = Settings; + m_SaveDisabled = SaveDisabled; + + if (m_SaveDisabled) + { + addButton.Enabled = false; + removeButton.Enabled = false; + } + + Generator.GenerateColumns(alertsListview, typeof(Alert), true); + Generator.GenerateColumns(talkgroupsListview, typeof(Talkgroup), true); + + // + // Hide unwanted columns. Cannot annotate classes with [OLVColumn] for xplat. + // + var hidden = new List() { + "Id","Talkgroups" }; + foreach (var col in alertsListview.AllColumns) + { + if (hidden.Any(c => col.Name == c)) + { + col.IsVisible = false; + } + } + + // + // Setup header styles + // + alertsListview.HeaderUsesThemes = false; + alertsListview.HeaderFormatStyle = new HeaderFormatStyle(); + alertsListview.HeaderFormatStyle.Normal = new HeaderStateStyle() + { + BackColor = Color.Honeydew, + }; + alertsListview.HeaderFormatStyle.Pressed = new HeaderStateStyle() + { + BackColor = Color.LightGreen, + }; + alertsListview.HeaderFormatStyle.Hot = new HeaderStateStyle() + { + BackColor = Color.LimeGreen, + }; + talkgroupsListview.HeaderUsesThemes = false; + talkgroupsListview.HeaderFormatStyle = new HeaderFormatStyle(); + talkgroupsListview.HeaderFormatStyle.Normal = new HeaderStateStyle() + { + BackColor = Color.Honeydew, + }; + talkgroupsListview.HeaderFormatStyle.Pressed = new HeaderStateStyle() + { + BackColor = Color.LightGreen, + }; + talkgroupsListview.HeaderFormatStyle.Hot = new HeaderStateStyle() + { + BackColor = Color.LimeGreen, + }; + alertsListview.RebuildColumns(); + alertsListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); + alertsListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize); + } + + private void LoadListviews() + { + talkgroupsListview.HideSelection = false; + // + // Load alerts from settings + // + alertsListview.SetObjects(m_Settings.Alerts); + // + // Load talkgroups from settings + // + talkgroupsListview.SetObjects(m_Settings.talkgroups); + + alertsListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); + alertsListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize); + talkgroupsListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); + talkgroupsListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize); + } + + private Alert GetAlert() + { + var alert = new Alert(); + object? freq; + if (alertFrequencyCombobox.SelectedItem == null || + string.IsNullOrEmpty((string)alertFrequencyCombobox.SelectedItem) || + !Enum.TryParse(typeof(AlertFrequency), (string)alertFrequencyCombobox.SelectedItem, out freq)) + { + throw new Exception("Invalid alert frequency"); + } + alert.Frequency = (AlertFrequency)freq; + alert.Name = alertNameTextbox.Text; + alert.Email = alertEmailTextbox.Text; + alert.Keywords = alertKeywordsTextbox.Text; + alert.CaptureWAV = captureWavCheckbox.Checked; + alert.Enabled = enableCheckbox.Checked; + if (applyToSelectedRadioButton.Checked) + { + if (string.IsNullOrEmpty(talkgroupsTextbox.Text) || + talkgroupsListview.SelectedObjects == null || + talkgroupsListview.SelectedObjects.Count == 0) + { + throw new Exception("At least one talkgroup must be selected"); + } + var selected = talkgroupsListview.SelectedObjects.Cast().ToList(); + alert.Talkgroups.AddRange(selected!.Select(s => s.Id).ToArray()); + } + alert.Validate(); + return alert; + } + + private void LoadAlert(Alert alert) + { + if (alert.Talkgroups.Count > 0) + { + applyToSelectedRadioButton.Checked = true; + applyToAllRadioButton.Checked = false; + talkgroupsTextbox.Text = string.Join(',', alert.Talkgroups.ToArray()); + } + else + { + applyToSelectedRadioButton.Checked = false; + applyToAllRadioButton.Checked = true; + talkgroupsTextbox.Enabled = false; + } + alertNameTextbox.Text = alert.Name; + alertEmailTextbox.Text = alert.Email; + alertKeywordsTextbox.Text = alert.Keywords; + captureWavCheckbox.Checked = alert.CaptureWAV; + enableCheckbox.Checked = alert.Enabled; + foreach (var item in alertFrequencyCombobox.Items) + { + if (!Enum.TryParse((string)item, out AlertFrequency value)) + { + continue; + } + if (value == alert.Frequency) + { + alertFrequencyCombobox.SelectedItem = item; + break; + } + } + addButton.Text = "Update"; + addButton.Tag = alert.Id; + } + + private void ResetForm() + { + addButton.Tag = null; + addButton.Text = "Add"; + applyToAllRadioButton.Checked = true; + applyToSelectedRadioButton.Checked = false; + talkgroupsTextbox.Enabled = false; + talkgroupsTextbox.Text = ""; + alertNameTextbox.Text = ""; + alertEmailTextbox.Text = ""; + alertKeywordsTextbox.Text = ""; + captureWavCheckbox.Checked = true; + enableCheckbox.Checked = true; + alertFrequencyCombobox.SelectedIndex = 0; + } + + private void addButton_Click(object sender, EventArgs e) + { + try + { + var data = GetAlert(); + // + // Update operation + // + if (addButton.Tag != null) + { + var alert = m_Settings.Alerts.Where(a => a.Id == (Guid)addButton.Tag).FirstOrDefault(); + if (alert == null) + { + throw new Exception($"Unable to locate existing alert with ID {(Guid)addButton.Tag}."); + } + alert.Name = data.Name; + alert.Email = data.Email; + alert.Keywords = data.Keywords; + alert.Talkgroups = data.Talkgroups; + alert.Frequency = data.Frequency; + alert.CaptureWAV = data.CaptureWAV; + alert.Enabled = data.Enabled; + alert.Validate(); + } + else + { + m_Settings.Alerts.Add(data); + } + ResetForm(); + alertsListview.SetObjects(m_Settings.Alerts); + alertsListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); + alertsListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize); + } + catch (Exception ex) + { + MessageBox.Show($"{ex.Message}"); + return; + } + } + + private void removeButton_Click(object sender, EventArgs e) + { + if (alertsListview.SelectedObjects != null && + alertsListview.SelectedObjects.Count > 1) + { + MessageBox.Show("Select one or more alerts to remove."); + return; + } + var selectedItems = alertsListview.SelectedObjects!.Cast().ToList(); + foreach (var alert in selectedItems) + { + if (!m_Settings.Alerts.Remove(alert)) + { + throw new Exception($"Unable to remove alert with ID {alert.Id}."); + } + } + alertsListview.RemoveObjects(selectedItems); + ResetForm(); + } + + private void AlertManagerWindow_Shown(object sender, EventArgs e) + { + if (m_Settings.talkgroups == null || m_Settings.talkgroups.Count == 0) + { + MessageBox.Show("Unable to load alert manager: no talkgroups"); + Close(); + return; + } + + LoadListviews(); + ResetForm(); + } + + private void alertsListview_SelectionChanged(object sender, EventArgs e) + { + if (alertsListview.SelectedObjects != null && + alertsListview.SelectedObjects.Count > 1) + { + // + // Multi-select treated as a bulk remove operation + // + return; + } + + if (alertsListview.SelectedObject == null) + { + ResetForm(); + return; + } + var alert = (Alert)alertsListview.SelectedObject; + LoadAlert(alert); + // + // Select appropriate TG(s) + // + talkgroupsListview.SelectedObjects = new List(); + if (alert.Talkgroups.Count > 0) + { + talkgroupsListview.SelectedObjects = talkgroupsListview.Objects.Cast().Where(tg => + alert.Talkgroups.Any(t => t == tg.Id)).ToList(); + talkgroupsListview.EnsureModelVisible(talkgroupsListview.SelectedObjects[0]); + } + talkgroupsListview.RefreshSelectedObjects(); + } + + private void applyToSelectedRadioButton_CheckedChanged(object sender, EventArgs e) + { + talkgroupsTextbox.Enabled = true; + } + + private void applyToAllRadioButton_CheckedChanged(object sender, EventArgs e) + { + talkgroupsTextbox.Text = ""; + talkgroupsTextbox.Enabled = false; + } + + private void talkgroupsListview_SelectionChanged(object sender, EventArgs e) + { + if (talkgroupsListview.SelectedObjects == null) + { + talkgroupsTextbox.Text = ""; + return; + } + talkgroupsTextbox.Enabled = true; + applyToSelectedRadioButton.Checked = true; + applyToAllRadioButton.Checked = false; + var selectedItems = talkgroupsListview.SelectedObjects.Cast().ToList(); + selectedItems.Sort(); + talkgroupsTextbox.Text = string.Join(',', selectedItems.Select(s => s.Id).ToArray()); + } + + private void AlertManagerWindow_FormClosing(object sender, FormClosingEventArgs e) + { + DialogResult = DialogResult.OK; + } + } +} diff --git a/pizzaui/AlertManagerWindow.resx b/pizzaui/AlertManagerWindow.resx new file mode 100644 index 0000000..8b82f69 --- /dev/null +++ b/pizzaui/AlertManagerWindow.resx @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + AAABAAEAICAAAAEAIACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAABMLAAATCwAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJSQmIh4dIXckIycNAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAA1NTcQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKikrCh0cH4dITln5f4yg/zs7 + P5MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAALSwtHyYrMGpJSEgQLy4vKzY3NgMAAAAAAAAAACAeIj8xOEHcfpWv/42l + vv9jo7r/OldizgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmJytvMoWg+Tc1NjMAAAAAPDw8BSclJw0fHiKWS1xw/nSS + rP9ge4j/WLra/13X/v8zTVmuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEhJTHM9coT7VFJTDwAAAAAfHiJLLjlE4mOH + p/9niqb/Wp63/0Ot0v9BrND/Xsnr/x0fJGUvMDEOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6OToFJSMlOgAAAAAfHyAxKSksWzZkduomJSgZHh4jqTtS + af9vpM7/S4Ce/zOfwv83uuT/QdD//1DR+v9ZnLP/KSktHCgmKBcgHyE7AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAGxsawMAAAAAAAAAAEFAQoQoJihUMTEyDQAAAAApS1mEK2h+/jBC + U/FgmcT/Z6DM/0CVt/8znsL/N7fi/zvL+v9D0Pz/VtX+/1l+jdMAAAAALCssDB8eIAcAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhHyIuK2By9CQjJiwfHyEUR0VHDCYkJTkrKiwKHh0hfyUy + Pe5Jdpn/Zaja/1GNr/81ocX/NKTK/zvE8v87yPf/NrLc/zmp0f9n0PP/P0tUjikpKShAQEEFAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB0bH2M5j6z/IyIlPjw8PQsAAAAAIyIlRCIr + NdVFeKD/ZbHq/1WJsP9Lla//Qcr4/zzD8P8+yvr/OsDt/z3O//86xPL/NZS5/2eqwf8eICZDAAAAAAAA + AAA5OToDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACYmJx4AAAAAHRwfcjWewP8mJScwIB4hVyAl + Lck1Wnf/W6zo/02Fsv9FcIX/Tr3h/zvE8v83sdr/PcLu/z3C8P8vjrD/LYao/zi44v9BuuH/aIiW5D4/ + RANYWVgHODk5CikpKhoAAAAAAAAAAAAAAAAAAAAAAAAAAE5OTgIzNDUMAAAAAAAAAAApVmeJNqvS/yI+ + SvEpQFD/TI7C/0yTx/85Y33/UZes/0fI8v83uuT/OLLc/z/G9P83cZH/PU2G/z5YlP84U43/MF6A/2nP + 8P9OUViGUFBRBQAAAAAuLi8rAAAAAAAAAAAAAAAAAAAAAC8vMAsAAAAAIyMkHQAAAAAkIyUoHR0fgShb + bug8vOf/PM3+/zq+6v9Pttb/XcDe/1zS9f8ymr7/NabN/z3G9f8/zv//M3KS/z9Vof8wXoX/LVKQ/yhT + hP8rVKr/R1Rk+R8hKB4xMTIYKyosKyoqKwwoJyktAAAAAAAAAAAAAAAAAAAAACcmKA8dHCB7JjE60TZS + af8qO0r/NnKG/0NpeP9zxuH/Pcj2/zzH9f8yncD/NKDE/z7K+P8/y/v/O7zo/0DO//8vWoz/MlWk/ylH + fP8yTKz/KViE/y1crP8fKUCVc3FyAQAAAAAlIyZELiwuCycmJ0EAAAAAAAAAAAAAAAAlIyYsJC4440Z1 + mP9Uk73/RXSW/z9ogv95oLD/Ztf+/z7N/f84s9z/NqrQ/ziz3P8qaYX/Iz5V/yM7VP8sa4b/Pb/s/zWU + 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 + /iPA/GAPwP8DH8Bx+D/geAB/+H8B//////8= + + + \ No newline at end of file diff --git a/pizzaui/AudioPlayer.cs b/pizzaui/AudioPlayer.cs new file mode 100644 index 0000000..aebfc86 --- /dev/null +++ b/pizzaui/AudioPlayer.cs @@ -0,0 +1,116 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using NAudio.Wave; +using System.Diagnostics; + +namespace pizzaui +{ + using static TraceLogger; + + internal class AudioPlayer + { + private WaveOutEvent m_Player; + + public AudioPlayer() + { + m_Player = new WaveOutEvent(); + } + + public void Shutdown() + { + m_Player.Stop(); + } + + public void Stop() + { + m_Player.Stop(); + } + + public void PlayMp3File(string FileName, Guid UniqueCallId, Func? CompletionCallback) + { + if (m_Player.PlaybackState == PlaybackState.Playing) + { + m_Player.Stop(); + } + + // + // NAudio plays the audio asynchronously, so we have to poll for completion. + // Because this is a blocking operation, we'll fire off a task. + // + Task.Run(() => + { + try + { + using (var reader = new Mp3FileReader(FileName)) + { + m_Player.Init(reader); + m_Player.Play(); + while (m_Player.PlaybackState == PlaybackState.Playing) + { + Thread.Sleep(500); + } + CompletionCallback?.Invoke(UniqueCallId); + } + } + catch (Exception ex) + { + Trace(TraceLoggerType.Utilities, + TraceEventType.Error, + $"Unable to play audio {FileName}: {ex.Message}"); + } + }); + } + + public void PlayWavFile(string FileName, Guid UniqueCallId, Func? CompletionCallback) + { + if (m_Player.PlaybackState == PlaybackState.Playing) + { + m_Player.Stop(); + } + + // + // NAudio plays the audio asynchronously, so we have to poll for completion. + // Because this is a blocking operation, we'll fire off a task. + // + Task.Run(() => + { + try + { + using (var reader = new WaveFileReader(FileName)) + { + var volumeStream = new Wave16ToFloatProvider(reader); + m_Player.Init(volumeStream); + m_Player.Play(); + while (m_Player.PlaybackState == PlaybackState.Playing) + { + Thread.Sleep(500); + } + CompletionCallback?.Invoke(UniqueCallId); + } + } + catch (Exception ex) + { + Trace(TraceLoggerType.Utilities, + TraceEventType.Error, + $"Unable to play audio {FileName}: {ex.Message}"); + } + }); + } + } +} diff --git a/pizzaui/HeadlessMode.cs b/pizzaui/HeadlessMode.cs new file mode 100644 index 0000000..36cdb03 --- /dev/null +++ b/pizzaui/HeadlessMode.cs @@ -0,0 +1,316 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Newtonsoft.Json; +using System.Text; +using pizzalib; + +namespace pizzaui +{ + using static TraceLogger; + using Whisper = pizzalib.Whisper; + + internal class HeadlessMode + { + private StreamServer? m_StreamServer; + private Whisper? m_Whisper; + private Alerter? m_Alerter; + private Settings? m_Settings; + + public HeadlessMode() + { + } + + public async Task Run(string[] HeadlessModeArgs) + { + // + // Redirect console output to parent process + // + var console = new WinConsole(); + console.Initialize(false); + TraceLogger.Initialize(true); + pizzalib.TraceLogger.Initialize(true); + Trace(TraceLoggerType.MainWindow, TraceEventType.Information, ""); + Trace(TraceLoggerType.MainWindow, TraceEventType.Information, "Pizzawave Headless Mode."); + Trace(TraceLoggerType.MainWindow, TraceEventType.Information, "Console initialized"); + + if (HeadlessModeArgs == null || HeadlessModeArgs.Length == 0) + { + PrintUsage("Error: No arguments provided."); + return 1; + } + + // + // Look. I've tried Microsoft's System.CommandLine for parsing and it's simply + // awful, awful awful. So this is all you'll get and YOU'LL LIKE IT. + // + string settingsPath = pizzalib.Settings.DefaultSettingsFileLocation; + foreach (var arg in HeadlessModeArgs) + { + if (arg.ToLower().StartsWith("--settings") || + arg.ToLower().StartsWith("-settings")) + { + if (!arg.Contains('=')) + { + PrintUsage($"Invalid settings file: {arg}"); + return 1; + } + var pieces = arg.Split('='); + if (pieces.Length != 2) + { + PrintUsage($"Invalid settings file: {arg}"); + return 1; + } + settingsPath = pieces[1]; + if (!File.Exists(settingsPath)) + { + PrintUsage($"Settings file doesn't exist: {settingsPath}"); + return 1; + } + break; + } + else if (arg.ToLower().StartsWith("--help") || arg.ToLower().StartsWith("-help")) + { + PrintUsage(); + return 0; + } + else if (arg.ToLower().StartsWith("--headless") || arg.ToLower().StartsWith("-headless")) + { + continue; + } + else + { + PrintUsage($"Unknown argument {arg}"); + return 1; + } + } + var result = await Initialize(settingsPath!); + if (!result) + { + return 1; + } + result = await StartServer(); // blocks until CTRL+C + TraceLogger.Shutdown(); + return result ? 0 : 1; + } + + private async Task Initialize(string SettingsPath) + { + if (!File.Exists(SettingsPath)) + { + Trace(TraceLoggerType.Headless, + TraceEventType.Warning, + $"Settings file {SettingsPath} does not exist, loading default..."); + m_Settings = new Settings(); + m_Settings.SaveToFile(SettingsPath); // persist it + } + else + { + try + { + var json = File.ReadAllText(SettingsPath); + m_Settings = (Settings)JsonConvert.DeserializeObject(json, typeof(Settings))!; + } + catch (Exception ex) + { + Trace(TraceLoggerType.Headless, TraceEventType.Error, $"{ex.Message}"); + return false; + } + } + + try + { + TraceLogger.SetLevel(m_Settings.TraceLevelApp); + pizzalib.TraceLogger.SetLevel(m_Settings.TraceLevelApp); + m_Whisper = new Whisper(m_Settings); + m_Alerter = new Alerter(m_Settings, m_Whisper, NewCallTranscribed); + m_StreamServer = new StreamServer(m_Alerter.NewCallDataAvailable, m_Settings); + _ = await m_Whisper.Initialize(); + } + catch (Exception ex) + { + Trace(TraceLoggerType.Headless, TraceEventType.Error, $"{ex.Message}"); + return false; + } + return true; + } + + private async Task StartServer() + { + try + { + Console.CancelKeyPress += (sender, eventArgs) => { + eventArgs.Cancel = true; + Trace(TraceLoggerType.Headless, TraceEventType.Information, "Server shutting down..."); + m_StreamServer?.Shutdown(); + }; + _ = await m_StreamServer?.Listen(); // blocks until CTRL+C + } + catch (Exception ex) + { + Trace(TraceLoggerType.Headless, TraceEventType.Error, $"{ex.Message}"); + return false; + } + + return true; + } + + private void NewCallTranscribed(TranscribedCall Call) + { + Trace(TraceLoggerType.Headless, TraceEventType.Verbose, $"{Call.ToString(m_Settings!)}"); + + var jsonContents = new StringBuilder(); + try + { + var jsonObject = JsonConvert.SerializeObject(Call, Formatting.Indented); + jsonContents.AppendLine(jsonObject); + } + catch (Exception ex) + { + Trace(TraceLoggerType.Headless, + TraceEventType.Error, + $"Failed to create JSON: {ex.Message}"); + return; + } + try + { + var target = Path.Combine(Settings.DefaultWorkingDirectory, + Settings.DefaultCallLogFileName); + using (var writer = new StreamWriter(target, true, Encoding.UTF8)) + { + writer.WriteLine(jsonContents.ToString()); + } + } + catch (Exception ex) + { + Trace(TraceLoggerType.Headless, + TraceEventType.Error, + $"Failed to save JSON: {ex.Message}"); + return; + } + } + + private void PrintUsage(string Message = null) + { + if (!string.IsNullOrEmpty(Message)) + { + Trace(TraceLoggerType.MainWindow, TraceEventType.Error, Message); + } + Trace(TraceLoggerType.MainWindow, + TraceEventType.Information, + "Usage: pizzawave.exe --headless [--settings=]"); + } + } + + internal class WinConsole : IDisposable + { + private bool m_Disposed = false; + private bool m_ConsoleCreatedOrAttached = false; + + public WinConsole() { } + + ~WinConsole() + { + Dispose(false); + } + + protected virtual void Dispose(bool disposing) + { + if (m_Disposed) + { + return; + } + + m_Disposed = true; + + if (m_ConsoleCreatedOrAttached) + { + FreeConsole(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public void Initialize(bool alwaysCreateNewConsole = true) + { + if (alwaysCreateNewConsole) + { + if (AllocConsole() == 0) + { + throw new Exception($"Unable to create console: {Marshal.GetLastWin32Error()}"); + } + m_ConsoleCreatedOrAttached = true; + } + + if (AttachConsole(ATTACH_PARENT) == 0) + { + var lastError = Marshal.GetLastWin32Error(); + if (lastError == ERROR_INVALID_HANDLE) // parent has no console! + { + if (m_ConsoleCreatedOrAttached) + { + throw new Exception("Unable to attach newly created console to parent (no handle)"); + } + if (AllocConsole() == 0) + { + throw new Exception($"Unable to create console: {Marshal.GetLastWin32Error()}"); + } + m_ConsoleCreatedOrAttached = true; + } + else + { + throw new Exception($"Unable to attach to process console: {lastError}"); + } + } + else + { + m_ConsoleCreatedOrAttached = true; + } + } + + [DllImport("kernel32.dll", + EntryPoint = "AllocConsole", + SetLastError = true, + CharSet = CharSet.Auto, + CallingConvention = CallingConvention.StdCall)] + private static extern int AllocConsole(); + + [DllImport("kernel32.dll", + EntryPoint = "FreeConsole", + SetLastError = true, + CharSet = CharSet.Auto, + CallingConvention = CallingConvention.StdCall)] + private static extern int FreeConsole(); + + [DllImport("kernel32.dll", + EntryPoint = "AttachConsole", + SetLastError = true, + CharSet = CharSet.Auto, + CallingConvention = CallingConvention.StdCall)] + private static extern uint AttachConsole(uint dwProcessId); + + private const uint ERROR_INVALID_HANDLE = 6; + private const uint ATTACH_PARENT = 0xFFFFFFFF; + } +} diff --git a/pizzaui/MainWindow.Designer.cs b/pizzaui/MainWindow.Designer.cs new file mode 100644 index 0000000..6675fd7 --- /dev/null +++ b/pizzaui/MainWindow.Designer.cs @@ -0,0 +1,519 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +namespace pizzaui +{ + public partial class MainWindow + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(MainWindow)); + tableLayoutPanel1 = new TableLayoutPanel(); + statusStrip1 = new StatusStrip(); + toolStripStatusLabel1 = new ToolStripStatusLabel(); + toolStripProgressBar1 = new ToolStripProgressBar(); + toolStripConnectionLabel = new ToolStripStatusLabel(); + transcriptionListview = new BrightIdeasSoftware.FastObjectListView(); + menuStrip1 = new MenuStrip(); + fileToolStripMenuItem = new ToolStripMenuItem(); + exportJSONToolStripMenuItem = new ToolStripMenuItem(); + exportCSVToolStripMenuItem = new ToolStripMenuItem(); + toolStripSeparator3 = new ToolStripSeparator(); + saveSettingsAsToolStripMenuItem = new ToolStripMenuItem(); + openSettingsToolStripMenuItem = new ToolStripMenuItem(); + toolStripSeparator1 = new ToolStripSeparator(); + startListeningToolStripMenuItem = new ToolStripMenuItem(); + startToolStripMenuItem = new ToolStripMenuItem(); + stopToolStripMenuItem = new ToolStripMenuItem(); + toolStripSeparator2 = new ToolStripSeparator(); + exitToolStripMenuItem = new ToolStripMenuItem(); + editToolStripMenuItem = new ToolStripMenuItem(); + settingsToolStripMenuItem = new ToolStripMenuItem(); + alertsToolStripMenuItem = new ToolStripMenuItem(); + viewToolStripMenuItem = new ToolStripMenuItem(); + clearToolStripMenuItem = new ToolStripMenuItem(); + showAlertMatchesOnlyToolStripMenuItem = new ToolStripMenuItem(); + groupByToolStripMenuItem = new ToolStripMenuItem(); + alphaTagToolStripMenuItem = new ToolStripMenuItem(); + tagToolStripMenuItem = new ToolStripMenuItem(); + descriptionToolStripMenuItem = new ToolStripMenuItem(); + categoryToolStripMenuItem = new ToolStripMenuItem(); + offGroupByToolStripMenuItem = new ToolStripMenuItem(); + diagnosticsToolStripMenuItem = new ToolStripMenuItem(); + openLogsToolStripMenuItem = new ToolStripMenuItem(); + traceLevelToolStripMenuItem = new ToolStripMenuItem(); + offTraceLevelToolStripMenuItem = new ToolStripMenuItem(); + errorToolStripMenuItem = new ToolStripMenuItem(); + warningToolStripMenuItem = new ToolStripMenuItem(); + informationToolStripMenuItem = new ToolStripMenuItem(); + verboseToolStripMenuItem = new ToolStripMenuItem(); + toolsToolStripMenuItem = new ToolStripMenuItem(); + transcriptionQualityToolStripMenuItem = new ToolStripMenuItem(); + findTalkgroupsToolStripMenuItem = new ToolStripMenuItem(); + cleanupToolStripMenuItem = new ToolStripMenuItem(); + helpToolStripMenuItem = new ToolStripMenuItem(); + githubToolStripMenuItem = new ToolStripMenuItem(); + tableLayoutPanel1.SuspendLayout(); + statusStrip1.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)transcriptionListview).BeginInit(); + menuStrip1.SuspendLayout(); + SuspendLayout(); + // + // tableLayoutPanel1 + // + tableLayoutPanel1.ColumnCount = 1; + tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); + tableLayoutPanel1.Controls.Add(statusStrip1, 0, 1); + tableLayoutPanel1.Controls.Add(transcriptionListview, 0, 0); + tableLayoutPanel1.Dock = DockStyle.Fill; + tableLayoutPanel1.Location = new Point(0, 33); + tableLayoutPanel1.Name = "tableLayoutPanel1"; + tableLayoutPanel1.RowCount = 2; + tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 97F)); + tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 3F)); + tableLayoutPanel1.Size = new Size(1872, 1112); + tableLayoutPanel1.TabIndex = 0; + // + // statusStrip1 + // + statusStrip1.Dock = DockStyle.Fill; + statusStrip1.ImageScalingSize = new Size(24, 24); + statusStrip1.Items.AddRange(new ToolStripItem[] { toolStripStatusLabel1, toolStripProgressBar1, toolStripConnectionLabel }); + statusStrip1.Location = new Point(0, 1078); + statusStrip1.Name = "statusStrip1"; + statusStrip1.Size = new Size(1872, 34); + statusStrip1.TabIndex = 0; + statusStrip1.Text = "statusStrip1"; + // + // toolStripStatusLabel1 + // + toolStripStatusLabel1.Name = "toolStripStatusLabel1"; + toolStripStatusLabel1.Size = new Size(0, 27); + toolStripStatusLabel1.Visible = false; + // + // toolStripProgressBar1 + // + toolStripProgressBar1.Name = "toolStripProgressBar1"; + toolStripProgressBar1.Size = new Size(100, 26); + toolStripProgressBar1.Visible = false; + // + // toolStripConnectionLabel + // + toolStripConnectionLabel.Name = "toolStripConnectionLabel"; + toolStripConnectionLabel.Size = new Size(1857, 27); + toolStripConnectionLabel.Spring = true; + toolStripConnectionLabel.Text = "Not connected"; + toolStripConnectionLabel.TextAlign = ContentAlignment.MiddleRight; + // + // transcriptionListview + // + transcriptionListview.CellEditUseWholeCell = false; + transcriptionListview.Dock = DockStyle.Fill; + transcriptionListview.FullRowSelect = true; + transcriptionListview.GridLines = true; + transcriptionListview.Location = new Point(3, 3); + transcriptionListview.Name = "transcriptionListview"; + transcriptionListview.ShowGroups = false; + transcriptionListview.Size = new Size(1866, 1072); + transcriptionListview.TabIndex = 1; + transcriptionListview.UseFiltering = true; + transcriptionListview.View = View.Details; + transcriptionListview.VirtualMode = true; + transcriptionListview.CellToolTipShowing += transcriptionListview_CellToolTipShowing; + // + // menuStrip1 + // + menuStrip1.ImageScalingSize = new Size(24, 24); + menuStrip1.Items.AddRange(new ToolStripItem[] { fileToolStripMenuItem, editToolStripMenuItem, viewToolStripMenuItem, diagnosticsToolStripMenuItem, toolsToolStripMenuItem, helpToolStripMenuItem }); + menuStrip1.Location = new Point(0, 0); + menuStrip1.Name = "menuStrip1"; + menuStrip1.Size = new Size(1872, 33); + menuStrip1.TabIndex = 1; + menuStrip1.Text = "menuStrip1"; + // + // fileToolStripMenuItem + // + fileToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { exportJSONToolStripMenuItem, exportCSVToolStripMenuItem, toolStripSeparator3, saveSettingsAsToolStripMenuItem, openSettingsToolStripMenuItem, toolStripSeparator1, startListeningToolStripMenuItem, toolStripSeparator2, exitToolStripMenuItem }); + fileToolStripMenuItem.Name = "fileToolStripMenuItem"; + fileToolStripMenuItem.Size = new Size(54, 29); + fileToolStripMenuItem.Text = "File"; + // + // exportJSONToolStripMenuItem + // + exportJSONToolStripMenuItem.Name = "exportJSONToolStripMenuItem"; + exportJSONToolStripMenuItem.Size = new Size(270, 34); + exportJSONToolStripMenuItem.Text = "Export JSON..."; + exportJSONToolStripMenuItem.Click += exportJSONToolStripMenuItem_Click; + // + // exportCSVToolStripMenuItem + // + exportCSVToolStripMenuItem.Name = "exportCSVToolStripMenuItem"; + exportCSVToolStripMenuItem.Size = new Size(270, 34); + exportCSVToolStripMenuItem.Text = "Export CSV..."; + exportCSVToolStripMenuItem.Click += exportCSVToolStripMenuItem_Click; + // + // toolStripSeparator3 + // + toolStripSeparator3.Name = "toolStripSeparator3"; + toolStripSeparator3.Size = new Size(267, 6); + // + // saveSettingsAsToolStripMenuItem + // + saveSettingsAsToolStripMenuItem.Name = "saveSettingsAsToolStripMenuItem"; + saveSettingsAsToolStripMenuItem.Size = new Size(270, 34); + saveSettingsAsToolStripMenuItem.Text = "Save settings as..."; + saveSettingsAsToolStripMenuItem.Click += saveSettingsAsToolStripMenuItem_Click; + // + // openSettingsToolStripMenuItem + // + openSettingsToolStripMenuItem.Name = "openSettingsToolStripMenuItem"; + openSettingsToolStripMenuItem.Size = new Size(270, 34); + openSettingsToolStripMenuItem.Text = "Load settings..."; + openSettingsToolStripMenuItem.Click += openSettingsToolStripMenuItem_Click; + // + // toolStripSeparator1 + // + toolStripSeparator1.Name = "toolStripSeparator1"; + toolStripSeparator1.Size = new Size(267, 6); + // + // startListeningToolStripMenuItem + // + startListeningToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { startToolStripMenuItem, stopToolStripMenuItem }); + startListeningToolStripMenuItem.Name = "startListeningToolStripMenuItem"; + startListeningToolStripMenuItem.Size = new Size(270, 34); + startListeningToolStripMenuItem.Text = "Server"; + // + // startToolStripMenuItem + // + startToolStripMenuItem.Name = "startToolStripMenuItem"; + startToolStripMenuItem.Size = new Size(151, 34); + startToolStripMenuItem.Text = "Start"; + startToolStripMenuItem.Click += startToolStripMenuItem_Click; + // + // stopToolStripMenuItem + // + stopToolStripMenuItem.Name = "stopToolStripMenuItem"; + stopToolStripMenuItem.Size = new Size(151, 34); + stopToolStripMenuItem.Text = "Stop"; + stopToolStripMenuItem.Click += stopToolStripMenuItem_Click; + // + // toolStripSeparator2 + // + toolStripSeparator2.Name = "toolStripSeparator2"; + toolStripSeparator2.Size = new Size(267, 6); + // + // exitToolStripMenuItem + // + exitToolStripMenuItem.Name = "exitToolStripMenuItem"; + exitToolStripMenuItem.Size = new Size(270, 34); + exitToolStripMenuItem.Text = "Exit"; + exitToolStripMenuItem.Click += exitToolStripMenuItem_Click; + // + // editToolStripMenuItem + // + editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { settingsToolStripMenuItem, alertsToolStripMenuItem }); + editToolStripMenuItem.Name = "editToolStripMenuItem"; + editToolStripMenuItem.Size = new Size(58, 29); + editToolStripMenuItem.Text = "Edit"; + // + // settingsToolStripMenuItem + // + settingsToolStripMenuItem.Name = "settingsToolStripMenuItem"; + settingsToolStripMenuItem.Size = new Size(190, 34); + settingsToolStripMenuItem.Text = "Settings..."; + settingsToolStripMenuItem.Click += settingsToolStripMenuItem_Click; + // + // alertsToolStripMenuItem + // + alertsToolStripMenuItem.Name = "alertsToolStripMenuItem"; + alertsToolStripMenuItem.Size = new Size(190, 34); + alertsToolStripMenuItem.Text = "Alerts..."; + alertsToolStripMenuItem.Click += alertsToolStripMenuItem_Click; + // + // viewToolStripMenuItem + // + viewToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { clearToolStripMenuItem, showAlertMatchesOnlyToolStripMenuItem, groupByToolStripMenuItem }); + viewToolStripMenuItem.Name = "viewToolStripMenuItem"; + viewToolStripMenuItem.Size = new Size(65, 29); + viewToolStripMenuItem.Text = "View"; + // + // clearToolStripMenuItem + // + clearToolStripMenuItem.Name = "clearToolStripMenuItem"; + clearToolStripMenuItem.Size = new Size(307, 34); + clearToolStripMenuItem.Text = "Clear"; + clearToolStripMenuItem.Click += clearToolStripMenuItem_Click; + // + // showAlertMatchesOnlyToolStripMenuItem + // + showAlertMatchesOnlyToolStripMenuItem.CheckOnClick = true; + showAlertMatchesOnlyToolStripMenuItem.Name = "showAlertMatchesOnlyToolStripMenuItem"; + showAlertMatchesOnlyToolStripMenuItem.Size = new Size(307, 34); + showAlertMatchesOnlyToolStripMenuItem.Text = "Show alert matches only"; + showAlertMatchesOnlyToolStripMenuItem.Click += showAlertMatchesOnlyToolStripMenuItem_Click; + // + // groupByToolStripMenuItem + // + groupByToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { alphaTagToolStripMenuItem, tagToolStripMenuItem, descriptionToolStripMenuItem, categoryToolStripMenuItem, offGroupByToolStripMenuItem }); + groupByToolStripMenuItem.Name = "groupByToolStripMenuItem"; + groupByToolStripMenuItem.Size = new Size(307, 34); + groupByToolStripMenuItem.Text = "Group by"; + // + // alphaTagToolStripMenuItem + // + alphaTagToolStripMenuItem.Checked = true; + alphaTagToolStripMenuItem.CheckOnClick = true; + alphaTagToolStripMenuItem.CheckState = CheckState.Checked; + alphaTagToolStripMenuItem.Name = "alphaTagToolStripMenuItem"; + alphaTagToolStripMenuItem.Size = new Size(204, 34); + alphaTagToolStripMenuItem.Text = "Alpha Tag"; + alphaTagToolStripMenuItem.Click += alphaTagToolStripMenuItem_Click; + // + // tagToolStripMenuItem + // + tagToolStripMenuItem.CheckOnClick = true; + tagToolStripMenuItem.Name = "tagToolStripMenuItem"; + tagToolStripMenuItem.Size = new Size(204, 34); + tagToolStripMenuItem.Text = "Tag"; + tagToolStripMenuItem.Click += tagToolStripMenuItem_Click; + // + // descriptionToolStripMenuItem + // + descriptionToolStripMenuItem.CheckOnClick = true; + descriptionToolStripMenuItem.Name = "descriptionToolStripMenuItem"; + descriptionToolStripMenuItem.Size = new Size(204, 34); + descriptionToolStripMenuItem.Text = "Description"; + descriptionToolStripMenuItem.Click += descriptionToolStripMenuItem_Click; + // + // categoryToolStripMenuItem + // + categoryToolStripMenuItem.CheckOnClick = true; + categoryToolStripMenuItem.Name = "categoryToolStripMenuItem"; + categoryToolStripMenuItem.Size = new Size(204, 34); + categoryToolStripMenuItem.Text = "Category"; + categoryToolStripMenuItem.Click += categoryToolStripMenuItem_Click; + // + // offGroupByToolStripMenuItem + // + offGroupByToolStripMenuItem.Name = "offGroupByToolStripMenuItem"; + offGroupByToolStripMenuItem.Size = new Size(204, 34); + offGroupByToolStripMenuItem.Text = "Off"; + offGroupByToolStripMenuItem.Click += offGroupByToolStripMenuItem_Click; + // + // diagnosticsToolStripMenuItem + // + diagnosticsToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { openLogsToolStripMenuItem, traceLevelToolStripMenuItem }); + diagnosticsToolStripMenuItem.Name = "diagnosticsToolStripMenuItem"; + diagnosticsToolStripMenuItem.Size = new Size(120, 29); + diagnosticsToolStripMenuItem.Text = "Diagnostics"; + // + // openLogsToolStripMenuItem + // + openLogsToolStripMenuItem.Name = "openLogsToolStripMenuItem"; + openLogsToolStripMenuItem.Size = new Size(209, 34); + openLogsToolStripMenuItem.Text = "Open logs..."; + openLogsToolStripMenuItem.Click += openLogsToolStripMenuItem_Click; + // + // traceLevelToolStripMenuItem + // + traceLevelToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { offTraceLevelToolStripMenuItem, errorToolStripMenuItem, warningToolStripMenuItem, informationToolStripMenuItem, verboseToolStripMenuItem }); + traceLevelToolStripMenuItem.Name = "traceLevelToolStripMenuItem"; + traceLevelToolStripMenuItem.Size = new Size(209, 34); + traceLevelToolStripMenuItem.Text = "Trace level"; + // + // offTraceLevelToolStripMenuItem + // + offTraceLevelToolStripMenuItem.CheckOnClick = true; + offTraceLevelToolStripMenuItem.Name = "offTraceLevelToolStripMenuItem"; + offTraceLevelToolStripMenuItem.Size = new Size(208, 34); + offTraceLevelToolStripMenuItem.Text = "Off"; + offTraceLevelToolStripMenuItem.Click += offTraceLevelToolStripMenuItem_Click; + // + // errorToolStripMenuItem + // + errorToolStripMenuItem.Checked = true; + errorToolStripMenuItem.CheckOnClick = true; + errorToolStripMenuItem.CheckState = CheckState.Checked; + errorToolStripMenuItem.Name = "errorToolStripMenuItem"; + errorToolStripMenuItem.Size = new Size(208, 34); + errorToolStripMenuItem.Text = "Error"; + errorToolStripMenuItem.Click += errorToolStripMenuItem_Click; + // + // warningToolStripMenuItem + // + warningToolStripMenuItem.CheckOnClick = true; + warningToolStripMenuItem.Name = "warningToolStripMenuItem"; + warningToolStripMenuItem.Size = new Size(208, 34); + warningToolStripMenuItem.Text = "Warning"; + warningToolStripMenuItem.Click += warningToolStripMenuItem_Click; + // + // informationToolStripMenuItem + // + informationToolStripMenuItem.CheckOnClick = true; + informationToolStripMenuItem.Name = "informationToolStripMenuItem"; + informationToolStripMenuItem.Size = new Size(208, 34); + informationToolStripMenuItem.Text = "Information"; + informationToolStripMenuItem.Click += informationToolStripMenuItem_Click; + // + // verboseToolStripMenuItem + // + verboseToolStripMenuItem.CheckOnClick = true; + verboseToolStripMenuItem.Name = "verboseToolStripMenuItem"; + verboseToolStripMenuItem.Size = new Size(208, 34); + verboseToolStripMenuItem.Text = "Verbose"; + verboseToolStripMenuItem.Click += verboseToolStripMenuItem_Click; + // + // toolsToolStripMenuItem + // + toolsToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { transcriptionQualityToolStripMenuItem, findTalkgroupsToolStripMenuItem, cleanupToolStripMenuItem }); + toolsToolStripMenuItem.Name = "toolsToolStripMenuItem"; + toolsToolStripMenuItem.Size = new Size(69, 29); + toolsToolStripMenuItem.Text = "Tools"; + // + // transcriptionQualityToolStripMenuItem + // + transcriptionQualityToolStripMenuItem.Name = "transcriptionQualityToolStripMenuItem"; + transcriptionQualityToolStripMenuItem.Size = new Size(272, 34); + transcriptionQualityToolStripMenuItem.Text = "Transcription quality"; + transcriptionQualityToolStripMenuItem.Click += transcriptionQualityToolStripMenuItem_Click; + // + // findTalkgroupsToolStripMenuItem + // + findTalkgroupsToolStripMenuItem.Name = "findTalkgroupsToolStripMenuItem"; + findTalkgroupsToolStripMenuItem.Size = new Size(272, 34); + findTalkgroupsToolStripMenuItem.Text = "Find talkgroups"; + findTalkgroupsToolStripMenuItem.Click += findTalkgroupsToolStripMenuItem_Click; + // + // cleanupToolStripMenuItem + // + cleanupToolStripMenuItem.Name = "cleanupToolStripMenuItem"; + cleanupToolStripMenuItem.Size = new Size(272, 34); + cleanupToolStripMenuItem.Text = "Cleanup..."; + cleanupToolStripMenuItem.Click += cleanupToolStripMenuItem_Click; + // + // helpToolStripMenuItem + // + helpToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { githubToolStripMenuItem }); + helpToolStripMenuItem.Name = "helpToolStripMenuItem"; + helpToolStripMenuItem.Size = new Size(65, 29); + helpToolStripMenuItem.Text = "Help"; + // + // githubToolStripMenuItem + // + githubToolStripMenuItem.Name = "githubToolStripMenuItem"; + githubToolStripMenuItem.Size = new Size(167, 34); + githubToolStripMenuItem.Text = "Github"; + githubToolStripMenuItem.Click += githubToolStripMenuItem_Click; + // + // MainWindow + // + AutoScaleDimensions = new SizeF(10F, 25F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(1872, 1145); + Controls.Add(tableLayoutPanel1); + Controls.Add(menuStrip1); + DoubleBuffered = true; + Icon = (Icon)resources.GetObject("$this.Icon"); + Name = "MainWindow"; + Text = "PizzaWave"; + FormClosing += MainWindow_FormClosing; + Shown += MainWindow_Shown; + tableLayoutPanel1.ResumeLayout(false); + tableLayoutPanel1.PerformLayout(); + statusStrip1.ResumeLayout(false); + statusStrip1.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)transcriptionListview).EndInit(); + menuStrip1.ResumeLayout(false); + menuStrip1.PerformLayout(); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private TableLayoutPanel tableLayoutPanel1; + private MenuStrip menuStrip1; + private ToolStripMenuItem fileToolStripMenuItem; + private ToolStripMenuItem exitToolStripMenuItem; + private ToolStripMenuItem editToolStripMenuItem; + private ToolStripMenuItem settingsToolStripMenuItem; + private StatusStrip statusStrip1; + private ToolStripStatusLabel toolStripStatusLabel1; + private ToolStripProgressBar toolStripProgressBar1; + private ToolStripMenuItem viewToolStripMenuItem; + private ToolStripStatusLabel toolStripConnectionLabel; + private BrightIdeasSoftware.FastObjectListView transcriptionListview; + private ToolStripMenuItem toolsToolStripMenuItem; + private ToolStripMenuItem transcriptionQualityToolStripMenuItem; + private ToolStripMenuItem findTalkgroupsToolStripMenuItem; + private ToolStripMenuItem alertsToolStripMenuItem; + private ToolStripMenuItem startListeningToolStripMenuItem; + private ToolStripMenuItem startToolStripMenuItem; + private ToolStripMenuItem stopToolStripMenuItem; + private ToolStripMenuItem clearToolStripMenuItem; + private ToolStripMenuItem showAlertMatchesOnlyToolStripMenuItem; + private ToolStripMenuItem diagnosticsToolStripMenuItem; + private ToolStripMenuItem openLogsToolStripMenuItem; + private ToolStripMenuItem traceLevelToolStripMenuItem; + private ToolStripMenuItem offTraceLevelToolStripMenuItem; + private ToolStripMenuItem errorToolStripMenuItem; + private ToolStripMenuItem warningToolStripMenuItem; + private ToolStripMenuItem informationToolStripMenuItem; + private ToolStripMenuItem verboseToolStripMenuItem; + private ToolStripMenuItem groupByToolStripMenuItem; + private ToolStripMenuItem alphaTagToolStripMenuItem; + private ToolStripMenuItem tagToolStripMenuItem; + private ToolStripMenuItem descriptionToolStripMenuItem; + private ToolStripMenuItem categoryToolStripMenuItem; + private ToolStripMenuItem offGroupByToolStripMenuItem; + private ToolStripMenuItem helpToolStripMenuItem; + private ToolStripMenuItem githubToolStripMenuItem; + private ToolStripMenuItem cleanupToolStripMenuItem; + private ToolStripMenuItem exportJSONToolStripMenuItem; + private ToolStripMenuItem exportCSVToolStripMenuItem; + private ToolStripSeparator toolStripSeparator3; + private ToolStripMenuItem saveSettingsAsToolStripMenuItem; + private ToolStripMenuItem openSettingsToolStripMenuItem; + private ToolStripSeparator toolStripSeparator1; + private ToolStripSeparator toolStripSeparator2; + } +} diff --git a/pizzaui/MainWindow.cs b/pizzaui/MainWindow.cs new file mode 100644 index 0000000..f18e0ad --- /dev/null +++ b/pizzaui/MainWindow.cs @@ -0,0 +1,711 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; +using Newtonsoft.Json; +using System.Text; +using CsvHelper; +using CsvHelper.Configuration; +using System.Globalization; +using pizzalib; + +namespace pizzaui +{ + using static TraceLogger; + using Whisper = pizzalib.Whisper; + + public partial class MainWindow : Form + { + private bool m_ShutdownComplete; + private bool m_ListenerStarted; + private bool m_CloseFormRequested; + private AudioPlayer m_AudioPlayer; + private StreamServer? m_StreamServer; + private Whisper? m_Whisper; + private Alerter? m_Alerter; + private Settings m_Settings; + + public MainWindow() + { + InitializeComponent(); + TraceLogger.Initialize(); + m_AudioPlayer = new AudioPlayer(); + string settingsPath = pizzalib.Settings.DefaultSettingsFileLocation; + if (!File.Exists(settingsPath)) + { + Trace(TraceLoggerType.MainWindow, + TraceEventType.Warning, + $"Settings file {settingsPath} does not exist, loading default..."); + m_Settings = new Settings(); + SetUiCallbacks(); + m_Settings.SaveToFile(string.Empty); // persist it + } + else + { + try + { + var json = File.ReadAllText(settingsPath); + m_Settings = (Settings)JsonConvert.DeserializeObject(json, typeof(Settings))!; + SetUiCallbacks(); + } + catch (Exception ex) + { + Trace(TraceLoggerType.MainWindow, + TraceEventType.Error, + $"Unable to load settings {settingsPath}: {ex.Message}"); + m_Settings = new Settings(); + SetUiCallbacks(); + return; + } + } + } + + private async void MainWindow_Shown(object sender, EventArgs e) + { + try + { + m_Whisper = new Whisper(m_Settings); + m_Alerter = new Alerter(m_Settings, m_Whisper, NewCallTranscribed); + m_StreamServer = new StreamServer(m_Alerter.NewCallDataAvailable, m_Settings); + _ = await ApplyNewSettings(); + } + catch (Exception ex) + { + MessageBox.Show($"Unable to apply settings: {ex.Message}"); + return; + } + } + + private void MainWindow_FormClosing(object sender, FormClosingEventArgs e) + { + m_AudioPlayer.Shutdown(); + TraceLogger.Shutdown(); + if (m_ListenerStarted && !m_ShutdownComplete) + { + m_CloseFormRequested = true; + m_StreamServer?.Shutdown(); + e.Cancel = true; + } + } + + #region file menuitem handlers + + private void exportJSONToolStripMenuItem_Click(object sender, EventArgs e) + { + if (transcriptionListview.FilteredObjects == null || + transcriptionListview.FilteredObjects.Cast().Count() == 0) + { + return; + } + + SaveFileDialog sfd = new SaveFileDialog(); + sfd.OverwritePrompt = true; + sfd.DefaultExt = "JSON"; + sfd.Filter = "JSON File (*.json)|*.json"; + + if (sfd.ShowDialog() != DialogResult.OK) + { + return; + } + var jsonContents = new StringBuilder(); + try + { + foreach (var obj in transcriptionListview.FilteredObjects) + { + var call = (TranscribedCall)obj; + var jsonObject = JsonConvert.SerializeObject(call, Formatting.Indented); + jsonContents.AppendLine(jsonObject); + } + } + catch (Exception ex) + { + MessageBox.Show($"Failed to export JSON: {ex.Message}"); + return; + } + try + { + File.WriteAllText(sfd.FileName, jsonContents.ToString()); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to save JSON: {ex.Message}"); + return; + } + + UpdateProgressLabel($"JSON exported to {sfd.FileName}"); + } + + private void exportCSVToolStripMenuItem_Click(object sender, EventArgs e) + { + if (transcriptionListview.FilteredObjects == null || + transcriptionListview.FilteredObjects.Cast().Count() == 0) + { + return; + } + SaveFileDialog sfd = new SaveFileDialog(); + sfd.OverwritePrompt = true; + sfd.DefaultExt = "CSV"; + sfd.Filter = "CSV File (*.csv)|*.csv"; + + if (sfd.ShowDialog() != DialogResult.OK) + { + return; + } + try + { + var configuration = new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + }; + + var filteredRecords = transcriptionListview.FilteredObjects.Cast().ToList(); + using (var writer = new StreamWriter(sfd.FileName, false, Encoding.UTF8)) + { + var csv = new CsvWriter(writer, configuration, false); + csv.WriteRecords(filteredRecords); + } + } + catch (Exception ex) + { + MessageBox.Show($"Failed to export CSV: {ex.Message}"); + return; + } + UpdateProgressLabel($"CSV exported to {sfd.FileName}"); + } + + private void saveSettingsAsToolStripMenuItem_Click(object sender, EventArgs e) + { + SaveFileDialog sfd = new SaveFileDialog(); + sfd.OverwritePrompt = true; + sfd.DefaultExt = "JSON"; + sfd.Filter = "JSON File (*.json)|*.json"; + + if (sfd.ShowDialog() != DialogResult.OK) + { + return; + } + + try + { + m_Settings.SaveToFile(sfd.FileName); + } + catch (Exception ex) + { + MessageBox.Show($"Unable to save settings to {sfd.FileName}: {ex.Message}"); + } + UpdateProgressLabel($"Settings saved to {sfd.FileName}"); + } + + private async void openSettingsToolStripMenuItem_Click(object sender, EventArgs e) + { + if (m_ListenerStarted) + { + MessageBox.Show("Please stop the server before loading new settings."); + return; + } + + OpenFileDialog dialog = new OpenFileDialog(); + dialog.CheckFileExists = true; + dialog.CheckPathExists = true; + dialog.Multiselect = false; + + if (dialog.ShowDialog() != DialogResult.OK) + { + return; + } + + try + { + var json = File.ReadAllText(dialog.FileName); + m_Settings = (Settings)JsonConvert.DeserializeObject(json, typeof(Settings))!; + _ = await ApplyNewSettings(); + } + catch (Exception ex) + { + MessageBox.Show($"Unable to load settings from {dialog.FileName}: {ex.Message}"); + } + UpdateProgressLabel($"Settings loaded from {dialog.FileName}"); + } + + private void startToolStripMenuItem_Click(object sender, EventArgs e) + { + if (!m_ListenerStarted) + { + StartServer(); + } + } + + private void stopToolStripMenuItem_Click(object sender, EventArgs e) + { + if (m_ListenerStarted) + { + UpdateProgressLabel("Stopping server..."); + stopToolStripMenuItem.Enabled = false; + m_StreamServer?.Shutdown(); + } + } + + private void exitToolStripMenuItem_Click(object sender, EventArgs e) + { + Application.Exit(); + } + + #endregion + + #region edit menuitem handlers + + private void alertsToolStripMenuItem_Click(object sender, EventArgs e) + { + // + // While it's possible to allow alert creation without talkgroups, it makes the + // experience much worse and adds unnecessary complexity. + // + if (m_Settings.talkgroups == null || m_Settings.talkgroups.Count == 0) + { + MessageBox.Show("Please import talkgroups in the Settings Window before creating alerts."); + return; + } + + var window = new AlertManagerWindow(m_Settings, m_ListenerStarted); + if (window.ShowDialog() == DialogResult.OK) + { + PersistSettingsSilent(); + } + } + + private void settingsToolStripMenuItem_Click(object sender, EventArgs e) + { + var window = new SettingsWindow(m_Settings, m_ListenerStarted); + if (window.ShowDialog() == DialogResult.OK) + { + // + // DialogResult.OK means at least something changed in Settings. + // + m_Settings = window.m_UpdatedSettings; + _ = ApplyNewSettings(); + } + } + + #endregion + + #region view menuitem handlers + + private void clearToolStripMenuItem_Click(object sender, EventArgs e) + { + DialogResult result = MessageBox.Show( + "Clear all call records?", + "Confirmation", + MessageBoxButtons.YesNoCancel); + if (result != DialogResult.Yes) + { + return; + } + transcriptionListview.ClearObjects(); + } + + private void showAlertMatchesOnlyToolStripMenuItem_Click(object sender, EventArgs e) + { + m_Settings.ShowAlertMatchesOnly = showAlertMatchesOnlyToolStripMenuItem.Checked; + PersistSettingsSilent(); + if (m_Settings.ShowAlertMatchesOnly) + { + ShowOnlyAlertMatches(); + } + else + { + ShowAllCalls(); + } + } + + #region group by + + private void alphaTagToolStripMenuItem_Click(object sender, EventArgs e) + { + alphaTagToolStripMenuItem.Checked = true; + m_Settings.GroupingStrategy = CallDisplayGrouping.AlphaTag; + PersistSettingsSilent(); + ApplyGroupByToForm(); + } + + private void tagToolStripMenuItem_Click(object sender, EventArgs e) + { + tagToolStripMenuItem.Checked = true; + m_Settings.GroupingStrategy = CallDisplayGrouping.Tag; + PersistSettingsSilent(); + ApplyGroupByToForm(); + } + + private void descriptionToolStripMenuItem_Click(object sender, EventArgs e) + { + descriptionToolStripMenuItem.Checked = true; + m_Settings.GroupingStrategy = CallDisplayGrouping.Description; + PersistSettingsSilent(); + ApplyGroupByToForm(); + } + + private void categoryToolStripMenuItem_Click(object sender, EventArgs e) + { + categoryToolStripMenuItem.Checked = true; + m_Settings.GroupingStrategy = CallDisplayGrouping.Category; + PersistSettingsSilent(); + ApplyGroupByToForm(); + } + + private void offGroupByToolStripMenuItem_Click(object sender, EventArgs e) + { + offGroupByToolStripMenuItem.Checked = true; + m_Settings.GroupingStrategy = CallDisplayGrouping.Off; + PersistSettingsSilent(); + ApplyGroupByToForm(); + } + + private void ApplyGroupByToForm() + { + var allItems = new Dictionary() { + { CallDisplayGrouping.Off, offGroupByToolStripMenuItem }, + { CallDisplayGrouping.AlphaTag, alphaTagToolStripMenuItem }, + { CallDisplayGrouping.Tag, tagToolStripMenuItem }, + { CallDisplayGrouping.Category, categoryToolStripMenuItem }, + { CallDisplayGrouping.Description, descriptionToolStripMenuItem }, + }; + var selectedItem = allItems[m_Settings.GroupingStrategy]; + selectedItem.Checked = true; + foreach (var kvp in allItems) + { + if (kvp.Key == m_Settings.GroupingStrategy) + { + continue; + } + kvp.Value.Checked = false; + } + SetGroupingStrategy(); // apply to the listview + } + #endregion + + #endregion + + #region diagnostics menuitem handlers + + private void openLogsToolStripMenuItem_Click(object sender, EventArgs e) + { + var psi = new ProcessStartInfo(); + psi.FileName = Settings.DefaultWorkingDirectory; + psi.WorkingDirectory = Settings.DefaultWorkingDirectory; + psi.UseShellExecute = true; + Process.Start(psi); + } + + #region trace level + private void offTraceLevelToolStripMenuItem_Click(object sender, EventArgs e) + { + offTraceLevelToolStripMenuItem.Checked = true; + m_Settings.TraceLevelApp = SourceLevels.Off; + PersistSettingsSilent(); + TraceLogger.SetLevel(m_Settings.TraceLevelApp); + ApplyTraceLevelToForm(); + } + + private void errorToolStripMenuItem_Click(object sender, EventArgs e) + { + errorToolStripMenuItem.Checked = true; + m_Settings.TraceLevelApp = SourceLevels.Error; + PersistSettingsSilent(); + TraceLogger.SetLevel(m_Settings.TraceLevelApp); + ApplyTraceLevelToForm(); + } + + private void warningToolStripMenuItem_Click(object sender, EventArgs e) + { + warningToolStripMenuItem.Checked = true; + m_Settings.TraceLevelApp = SourceLevels.Warning; + PersistSettingsSilent(); + TraceLogger.SetLevel(m_Settings.TraceLevelApp); + ApplyTraceLevelToForm(); + } + + private void informationToolStripMenuItem_Click(object sender, EventArgs e) + { + informationToolStripMenuItem.Checked = true; + m_Settings.TraceLevelApp = SourceLevels.Information; + PersistSettingsSilent(); + TraceLogger.SetLevel(m_Settings.TraceLevelApp); + ApplyTraceLevelToForm(); + } + + private void verboseToolStripMenuItem_Click(object sender, EventArgs e) + { + verboseToolStripMenuItem.Checked = true; + m_Settings.TraceLevelApp = SourceLevels.Verbose; + PersistSettingsSilent(); + TraceLogger.SetLevel(m_Settings.TraceLevelApp); + ApplyTraceLevelToForm(); + } + + private void ApplyTraceLevelToForm() + { + var allItems = new Dictionary() { + { SourceLevels.Off, offTraceLevelToolStripMenuItem }, + { SourceLevels.Error, errorToolStripMenuItem }, + { SourceLevels.Warning, warningToolStripMenuItem }, + { SourceLevels.Information, informationToolStripMenuItem }, + { SourceLevels.Verbose, verboseToolStripMenuItem }, + }; + var selectedItem = allItems[m_Settings.TraceLevelApp]; + selectedItem.Checked = true; + foreach (var kvp in allItems) + { + if (kvp.Key == m_Settings.TraceLevelApp) + { + continue; + } + kvp.Value.Checked = false; + } + } + #endregion + + #endregion + + #region tools menuitem handlers + private void transcriptionQualityToolStripMenuItem_Click(object sender, EventArgs e) + { + + } + + private void findTalkgroupsToolStripMenuItem_Click(object sender, EventArgs e) + { + + } + + private void cleanupToolStripMenuItem_Click(object sender, EventArgs e) + { + var alertsDir = Settings.DefaultAlertWavLocation; + var logsDir = TraceLogger.m_TraceFileDir; + var dirs = new List() { alertsDir, logsDir }; + var numDeleted = 0; + foreach (var dir in dirs) + { + foreach (var file in Directory.GetFiles(dir)) + { + try + { + File.Delete(file); + numDeleted++; + } + catch { } + } + } + MessageBox.Show($"Deleted {numDeleted} files."); + } + #endregion + + #region help menuitem handlers + private void githubToolStripMenuItem_Click(object sender, EventArgs e) + { + Utilities.LaunchBrowser(@"http://www.github.com/lilhoser/pizzawave"); + } + #endregion + + #region settings management + + private async Task ApplyNewSettings() + { + if (m_ListenerStarted) + { + MessageBox.Show("Cannot apply new settings when server is active."); + return false; + } + + // + // This routine can be called: + // by MainWindow_Shown on app startup + // when SettingsWindow has been closed + // when user selects File->Load settings... + // In all cases, the new settings must be already applied in m_Settings. + // It's important that the server is not active. + // + TraceLogger.SetLevel(m_Settings.TraceLevelApp); + ApplySettingsToForm(); + InitializeListview(); + + try + { + _ = await m_Whisper!.Initialize(); + } + catch (Exception ex) + { + Trace(TraceLoggerType.MainWindow, TraceEventType.Error, $"{ex.Message}"); + return false; + } + + if (m_Settings.AutostartListener) + { + StartServer(); + } + + return true; + } + + private void ApplySettingsToForm() + { + // + // See settings.cs - only some of the settings are managed from MainWindow + // + ApplyTraceLevelToForm(); + ApplyGroupByToForm(); + showAlertMatchesOnlyToolStripMenuItem.Checked = m_Settings.ShowAlertMatchesOnly; + } + + private void PersistSettingsSilent() + { + try + { + m_Settings.SaveToFile(string.Empty); + } + catch (Exception ex) + { + MessageBox.Show($"{ex.Message}"); + Trace(TraceLoggerType.MainWindow, + TraceEventType.Error, + $"Unable to persist a settings change: {ex.Message}"); + } + } + + #endregion + + #region stream server management + + private void StartServer() + { + startToolStripMenuItem.Enabled = false; + stopToolStripMenuItem.Enabled = true; + UpdateProgressLabel("Server started"); + Task.Run(async () => + { + try + { + m_ListenerStarted = true; + var result = await m_StreamServer!.Listen(); + if (result) + { + m_ShutdownComplete = true; + if (m_CloseFormRequested) + { + this.Invoke((MethodInvoker)(() => Close())); + return; + } + } + m_ListenerStarted = false; + startToolStripMenuItem.Enabled = true; + stopToolStripMenuItem.Enabled = false; + UpdateProgressLabel("Server stopped"); + UpdateConnectionLabel("Not connected"); + } + catch (Exception ex) + { + m_ListenerStarted = false; + Trace(TraceLoggerType.MainWindow, TraceEventType.Error, $"{ex.Message}"); + } + }); + } + #endregion + + #region process call transcription data + + private void NewCallTranscribed(TranscribedCall Call) + { + transcriptionListview.Invoke((MethodInvoker)(() => + { + var focused = transcriptionListview.FocusedObject; + var focusedItem = transcriptionListview.FocusedItem; + transcriptionListview.AddObject(Call); + if (focused != null) + { + transcriptionListview.EnsureModelVisible(focused); + transcriptionListview.FocusedObject = focused; + transcriptionListview.TopItem = focusedItem; + } + })); + } + + #endregion + + #region UI progress callbacks + + private void SetUiCallbacks() + { + m_Settings.UpdateConnectionLabelCallback = UpdateConnectionLabel; + m_Settings.UpdateProgressLabelCallback = UpdateProgressLabel; + m_Settings.ProgressBarStepCallback = ProgressBarStep; + m_Settings.HideProgressBarCallback = HideProgressBar; + m_Settings.SetProgressBarCallback = SetProgressBar; + } + + public void UpdateProgressLabel(string Message) + { + statusStrip1.Invoke((MethodInvoker)(() => + { + toolStripStatusLabel1.Visible = true; + toolStripStatusLabel1.Text = Message; + })); + } + + public void UpdateConnectionLabel(string Message) + { + statusStrip1.Invoke((MethodInvoker)(() => + { + toolStripConnectionLabel.Visible = true; + toolStripConnectionLabel.Text = Message; + })); + } + + public void ProgressBarStep() + { + statusStrip1.Invoke((MethodInvoker)(() => + { + toolStripProgressBar1.PerformStep(); + if (toolStripProgressBar1.Value == toolStripProgressBar1.Maximum) + { + HideProgressBar(); // all done + } + })); + } + + public void SetProgressBar(int TotalStep, int Step) + { + statusStrip1.Invoke((MethodInvoker)(() => + { + toolStripProgressBar1.Visible = true; + toolStripProgressBar1.Maximum = TotalStep; + toolStripProgressBar1.Step = Step; + toolStripProgressBar1.Value = 0; + })); + } + + public void HideProgressBar() + { + statusStrip1.Invoke((MethodInvoker)(() => + { + toolStripProgressBar1.Visible = false; + toolStripProgressBar1.Maximum = 0; + toolStripProgressBar1.Step = 0; + toolStripProgressBar1.Value = 0; + })); + } + #endregion + } +} diff --git a/pizzaui/MainWindow.resx b/pizzaui/MainWindow.resx new file mode 100644 index 0000000..2f18f06 --- /dev/null +++ b/pizzaui/MainWindow.resx @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 177, 17 + + + + + AAABAAEAICAAAAEAIACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAABMLAAATCwAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJSQmIh4dIXckIycNAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAA1NTcQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKikrCh0cH4dITln5f4yg/zs7 + P5MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAALSwtHyYrMGpJSEgQLy4vKzY3NgMAAAAAAAAAACAeIj8xOEHcfpWv/42l + vv9jo7r/OldizgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmJytvMoWg+Tc1NjMAAAAAPDw8BSclJw0fHiKWS1xw/nSS + rP9ge4j/WLra/13X/v8zTVmuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEhJTHM9coT7VFJTDwAAAAAfHiJLLjlE4mOH + p/9niqb/Wp63/0Ot0v9BrND/Xsnr/x0fJGUvMDEOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6OToFJSMlOgAAAAAfHyAxKSksWzZkduomJSgZHh4jqTtS + af9vpM7/S4Ce/zOfwv83uuT/QdD//1DR+v9ZnLP/KSktHCgmKBcgHyE7AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAGxsawMAAAAAAAAAAEFAQoQoJihUMTEyDQAAAAApS1mEK2h+/jBC + U/FgmcT/Z6DM/0CVt/8znsL/N7fi/zvL+v9D0Pz/VtX+/1l+jdMAAAAALCssDB8eIAcAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhHyIuK2By9CQjJiwfHyEUR0VHDCYkJTkrKiwKHh0hfyUy + Pe5Jdpn/Zaja/1GNr/81ocX/NKTK/zvE8v87yPf/NrLc/zmp0f9n0PP/P0tUjikpKShAQEEFAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB0bH2M5j6z/IyIlPjw8PQsAAAAAIyIlRCIr + NdVFeKD/ZbHq/1WJsP9Lla//Qcr4/zzD8P8+yvr/OsDt/z3O//86xPL/NZS5/2eqwf8eICZDAAAAAAAA + AAA5OToDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACYmJx4AAAAAHRwfcjWewP8mJScwIB4hVyAl + Lck1Wnf/W6zo/02Fsv9FcIX/Tr3h/zvE8v83sdr/PcLu/z3C8P8vjrD/LYao/zi44v9BuuH/aIiW5D4/ + RANYWVgHODk5CikpKhoAAAAAAAAAAAAAAAAAAAAAAAAAAE5OTgIzNDUMAAAAAAAAAAApVmeJNqvS/yI+ + SvEpQFD/TI7C/0yTx/85Y33/UZes/0fI8v83uuT/OLLc/z/G9P83cZH/PU2G/z5YlP84U43/MF6A/2nP + 8P9OUViGUFBRBQAAAAAuLi8rAAAAAAAAAAAAAAAAAAAAAC8vMAsAAAAAIyMkHQAAAAAkIyUoHR0fgShb + bug8vOf/PM3+/zq+6v9Pttb/XcDe/1zS9f8ymr7/NabN/z3G9f8/zv//M3KS/z9Vof8wXoX/LVKQ/yhT + hP8rVKr/R1Rk+R8hKB4xMTIYKyosKyoqKwwoJyktAAAAAAAAAAAAAAAAAAAAACcmKA8dHCB7JjE60TZS + af8qO0r/NnKG/0NpeP9zxuH/Pcj2/zzH9f8yncD/NKDE/z7K+P8/y/v/O7zo/0DO//8vWoz/MlWk/ylH + fP8yTKz/KViE/y1crP8fKUCVc3FyAQAAAAAlIyZELiwuCycmJ0EAAAAAAAAAAAAAAAAlIyYsJC4440Z1 + mP9Uk73/RXSW/z9ogv95oLD/Ztf+/z7N/f84s9z/NqrQ/ziz3P8qaYX/Iz5V/yM7VP8sa4b/Pb/s/zWU + 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 + /iPA/GAPwP8DH8Bx+D/geAB/+H8B//////8= + + + \ No newline at end of file diff --git a/pizzaui/MainWindowListview.cs b/pizzaui/MainWindowListview.cs new file mode 100644 index 0000000..9e46ae9 --- /dev/null +++ b/pizzaui/MainWindowListview.cs @@ -0,0 +1,414 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using BrightIdeasSoftware; +using pizzalib; +using System.Text.RegularExpressions; + +namespace pizzaui +{ + public partial class MainWindow + { + private bool m_InitializedOnce = false; + + private class ObjectListviewColumnHandlers + { + public GroupKeyGetterDelegate? groupKeyGetterDelegate; + public AspectToStringConverterDelegate? aspectToStringConverterDelegate; + public ImageGetterDelegate? imageGetterDelegate; + public AspectGetterDelegate? aspectGetterDelegate; + public GroupFormatterDelegate? groupFormatterDelegate; + public string? columnName; + } + + public enum CallDisplayGrouping + { + Off, + AlphaTag, + Tag, + Description, + Category + } + + private static Dictionary s_columnHandlerTable = + new Dictionary(); + + private void InitializeListview() + { + // + // This routine can be called multiple times during the app, as new settings + // are loaded or changed. + // + transcriptionListview.Clear(); + + // + // Generate columns and set default sort. + // + Generator.GenerateColumns(transcriptionListview, typeof(TranscribedCall), true); + transcriptionListview.PrimarySortColumn = transcriptionListview.GetColumn("Start Time"); + transcriptionListview.PrimarySortOrder = SortOrder.Descending; + // + // Header style/formatting. + // + transcriptionListview.HeaderUsesThemes = false; + transcriptionListview.HeaderFormatStyle = new HeaderFormatStyle(); + transcriptionListview.HeaderFormatStyle.Normal = new HeaderStateStyle() + { + BackColor = Color.Bisque, + }; + transcriptionListview.HeaderFormatStyle.Pressed = new HeaderStateStyle() + { + BackColor = Color.DarkOrange, + }; + transcriptionListview.HeaderFormatStyle.Hot = new HeaderStateStyle() + { + BackColor = Color.Orange, + }; + + // + // Popup balloon for full transcription text. + // + transcriptionListview.CellToolTip.Font = new Font("MS Sans Serif", 12); + transcriptionListview.CellToolTip.BackColor = Color.LightBlue; + transcriptionListview.CellToolTip.Title = "Full Transcription"; + transcriptionListview.CellToolTip.IsBalloon = true; + transcriptionListview.CellToolTip.AutoPopDelay = 60000; // 60s + transcriptionListview.CellToolTip.InitialDelay = 0; + // + // Row height to allow for button + // + transcriptionListview.RowHeight = 35; + // + // Setup how rows will be grouped, if at all. + // + SetGroupingStrategy(); + // + // Rows that match an alert get highlighted, unless the user has specified + // they only want to show matches in the UI. + // + void rowHighlightHandler(object? sender, FormatRowEventArgs e) + { + var call = (TranscribedCall)e.Model; + if (call.IsAlertMatch && !m_Settings.ShowAlertMatchesOnly) + { + e.Item.BackColor = Color.Orange; + } + } + if (m_InitializedOnce) + { + transcriptionListview.FormatRow -= rowHighlightHandler; + } + transcriptionListview.FormatRow += rowHighlightHandler; + + // + // Configure aspect and other getters for each column. + // + s_columnHandlerTable = new Dictionary() + { + { "StartTime", new ObjectListviewColumnHandlers() { + aspectToStringConverterDelegate = new AspectToStringConverterDelegate((value) => + { + if (value == null) + { + return ""; + } + DateTime date = DateTimeOffset.FromUnixTimeSeconds((long)value).ToLocalTime().DateTime; + return date.ToString("M/d/yyyy h:mm tt"); + }), + columnName = "Start Time" // OLV does this.. + } + }, + { "StopTime", new ObjectListviewColumnHandlers() { + aspectToStringConverterDelegate = new AspectToStringConverterDelegate((value) => + { + if (value == null) + { + return ""; + } + DateTime date = DateTimeOffset.FromUnixTimeSeconds((long)value).ToLocalTime().DateTime; + return date.ToString("M/d/yyyy h:mm tt"); + }), + columnName = "Stop Time" // OLV does this.. + } + }, + { "Source", new ObjectListviewColumnHandlers() { } }, + { "SystemShortName", new ObjectListviewColumnHandlers() { columnName= "System Short Name" } }, + { "CallId", new ObjectListviewColumnHandlers() { columnName = "Call Id" } }, + { "Talkgroup", new ObjectListviewColumnHandlers() { + groupKeyGetterDelegate = new GroupKeyGetterDelegate((rowObject) => + { + var call = (TranscribedCall)rowObject; + var talkgroup = TalkgroupHelper.LookupTalkgroup(m_Settings, call.Talkgroup); + if (talkgroup == null) + { + return $"{call.Talkgroup}"; + } + switch(m_Settings.GroupingStrategy) + { + case CallDisplayGrouping.AlphaTag: + { + return talkgroup.AlphaTag; + } + case CallDisplayGrouping.Tag: + { + return talkgroup.Tag; + } + case CallDisplayGrouping.Description: + { + return talkgroup.Description; + } + case CallDisplayGrouping.Category: + { + return talkgroup.Category; + } + case CallDisplayGrouping.Off: + default: + { + return string.Empty; + } + } + }), + groupFormatterDelegate = new GroupFormatterDelegate((group, parameters) => + { + if (group.Items == null || group.Items.Count == 0) + { + return; + } + var row = (TranscribedCall)group.Items[0].RowObject; + var talkgroup = TalkgroupHelper.LookupTalkgroup(m_Settings, row.Talkgroup); + if (talkgroup == null) + { + return; + } + group.Subtitle = $"{talkgroup}"; + }), + } }, + { "Frequency", new ObjectListviewColumnHandlers() { } }, + { "Transcription", new ObjectListviewColumnHandlers() { } }, + }; + + foreach (var kvp in s_columnHandlerTable) + { + var fieldName = kvp.Key; + var table = kvp.Value; + var colname = table.columnName; + if (string.IsNullOrEmpty(colname)) + { + colname = fieldName; + } + var col = transcriptionListview.GetColumn(colname); + col.AspectName = fieldName; + if (table.groupKeyGetterDelegate != null) + { + col.GroupKeyGetter = table.groupKeyGetterDelegate; + } + if (table.aspectToStringConverterDelegate != null) + { + col.AspectToStringConverter = table.aspectToStringConverterDelegate; + } + if (table.imageGetterDelegate != null) + { + col.ImageGetter = table.imageGetterDelegate; + } + if (table.aspectGetterDelegate != null) + { + col.AspectGetter = table.aspectGetterDelegate; + } + if (table.groupFormatterDelegate != null) + { + col.GroupFormatter = table.groupFormatterDelegate; + } + } + + // + // Force columns to be wider. The control's builtin "make it as big as the + // header AND the data" has _never_ worked right. + // Also, hide some columns that were generated because we used the OLV + // feature to import all public members. We have to use this approach + // because pizzalib is not Windows-only (which OLV is), so we cannot + // annotate OLV-visible fields with [OLVColumn]. + // + var hiddenColumns = new List() { "Source", "PatchedTalkgroups", "IsAudioPlaying", "UniqueId" }; + foreach (var col in transcriptionListview.AllColumns) + { + col.MinimumWidth = 125; + if (hiddenColumns.Any(c => c == col.Name)) + { + col.IsVisible = false; + } + } + + // + // Setup the "Location" aspect to hold a button for playing the audio file + // + InitializeButtonColumn(); + + transcriptionListview.RebuildColumns(); + transcriptionListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); + transcriptionListview.AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize); + + m_InitializedOnce = true; + } + + private void transcriptionListview_CellToolTipShowing(object sender, BrightIdeasSoftware.ToolTipShowingEventArgs e) + { + if (e.Column.Name != "Transcription") + { + return; + } + + var transcribedCall = (TranscribedCall)e.Model; + e.Text = Utilities.Wordwrap(transcribedCall.Transcription, 150); + } + + private void InitializeButtonColumn() + { + // + // Rename "Location" to "Audio" and place a button for playing it. + // + var playButtonColumn = transcriptionListview.GetColumn("Location"); + playButtonColumn.Text = "Audio"; + playButtonColumn.AspectGetter = delegate (object row) + { + var call = (TranscribedCall)row; + if (string.IsNullOrEmpty(call.Location)) + { + // + // There is no audio to play at all. Show nothing in the cell + // + return string.Empty; + } + if (call.IsAudioPlaying) + { + // + // There's an audio file and it's currently playing. Set the button text + // to "Stop". + // + return "Stop"; + } + return "Play"; + }; + playButtonColumn.MinimumWidth = 85; + playButtonColumn.ButtonSize = new Size(75, 32); + playButtonColumn.ButtonSizing = OLVColumn.ButtonSizingMode.FixedBounds; + playButtonColumn.TextAlign = HorizontalAlignment.Center; + playButtonColumn.EnableButtonWhenItemIsDisabled = true; + transcriptionListview.ShowImagesOnSubItems = true; + + void ButtonClickHandler(object? sender, CellClickEventArgs e) + { + var row = (TranscribedCall)e.Model; + if (e.Column.Name != "Location") + { + return; + } + + // + // User clicked "Stop" button - reset to "Start" + // + if (row.IsAudioPlaying) + { + m_AudioPlayer.Stop(); + row.IsAudioPlaying = false; + transcriptionListview.RefreshObject(row); + return; + } + + // + // User clicked "Play" button - begin playing and set button text to "Stop" + // + try + { + m_AudioPlayer.PlayMp3File( + row.Location, row.UniqueId, new Func((Guid Id) => + { + // + // Upon completion of playing the MP3, we have to set the + // button text back to "Play". Because the listview could + // have been cleared in the interim, we search for it. + // Note: we also must invoke this from the main UI thread. + // + return transcriptionListview.Invoke(() => + { + if (transcriptionListview.Objects != null) + { + foreach (var obj in transcriptionListview.Objects) + { + var row = (TranscribedCall)e.Model; + if (row.UniqueId == Id) + { + row.IsAudioPlaying = false; + transcriptionListview.RefreshObject(row); + return true; + } + } + } + return false; + }); + })); + row.IsAudioPlaying = true; + transcriptionListview.RefreshObject(row); + } + catch (Exception ex) + { + m_AudioPlayer.Stop(); + row.IsAudioPlaying = false; + transcriptionListview.RefreshObject(row); + MessageBox.Show(ex.Message); + return; + } + } + + if (m_InitializedOnce) + { + transcriptionListview.ButtonClick -= ButtonClickHandler; + } + + transcriptionListview.ButtonClick += ButtonClickHandler; + playButtonColumn.IsButton = true; + } + + private void SetGroupingStrategy() + { + if (m_Settings.GroupingStrategy != CallDisplayGrouping.Off) + { + transcriptionListview.SpaceBetweenGroups = 15; + transcriptionListview.SortGroupItemsByPrimaryColumn = true; + transcriptionListview.ShowGroups = true; + transcriptionListview.AlwaysGroupByColumn = transcriptionListview.GetColumn("Talkgroup"); + transcriptionListview.ShowItemCountOnGroups = true; + } + } + + private void ShowOnlyAlertMatches() + { + // + // Note: if filtering becomes any more complex, switch to CompositeAllFilter + // + transcriptionListview.AdditionalFilter = new ModelFilter(delegate (object row) { + var call = (TranscribedCall)row; + return call.IsAlertMatch; + }); + } + + private void ShowAllCalls() + { + transcriptionListview.AdditionalFilter = null; + } + } +} diff --git a/pizzaui/Program.cs b/pizzaui/Program.cs new file mode 100644 index 0000000..72b540c --- /dev/null +++ b/pizzaui/Program.cs @@ -0,0 +1,58 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +namespace pizzaui +{ + internal static class Program + { + + static async Task Main(string[] HeadlessModeArgs) + { + if (HeadlessModeArgs.Length > 0) + { + var headless = new HeadlessMode(); + return await headless.Run(HeadlessModeArgs); + } + else + { + // + // We can't mark `Main` as `STAThread` because it has `async` attribute, + // which is required to run in headless/console mode. + // + // Start a new thread with a static non-async routine properly marked. + // + var thread = new Thread(MainStaThread); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + return 0; + } + } + + [STAThread] + static void MainStaThread() + { + // + // To customize application configuration such as set high DPI settings or default font, + // see https://aka.ms/applicationconfiguration. + // + ApplicationConfiguration.Initialize(); + Application.Run(new MainWindow()); + } + } +} \ No newline at end of file diff --git a/pizzaui/Properties/Resources.Designer.cs b/pizzaui/Properties/Resources.Designer.cs new file mode 100644 index 0000000..8a79901 --- /dev/null +++ b/pizzaui/Properties/Resources.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace pizzawave.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("pizzawave.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/pizzaui/Properties/Resources.resx b/pizzaui/Properties/Resources.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/pizzaui/Properties/Resources.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/pizzaui/Properties/launchSettings.json b/pizzaui/Properties/launchSettings.json new file mode 100644 index 0000000..fb0996d --- /dev/null +++ b/pizzaui/Properties/launchSettings.json @@ -0,0 +1,7 @@ +{ + "profiles": { + "pizzawave": { + "commandName": "Project" + } + } +} \ No newline at end of file diff --git a/pizzaui/README.md b/pizzaui/README.md new file mode 100644 index 0000000..22dd936 --- /dev/null +++ b/pizzaui/README.md @@ -0,0 +1,89 @@ + +# Introduction +![plot](../docs/logo-med.png#right) `pizzaui` is a multi-threaded .NET user-interface application built on top of the [`pizzalib`](https://github.com/lilhoser/pizzawave/tree/main/pizzalib) library. + +![plot](../docs/screenshot1.png) + +Please be sure to read the [`pizzawave` README page](https://github.com/lilhoser/pizzawave) + +# Requirements +* [Requirements as specified in the `pizzawave` README](https://github.com/lilhoser/pizzawave) +* [Requirements as specified in the `pizzalib` README]((https://github.com/lilhoser/pizzawave/tree/main/pizzalib) +* A Windows system running .NET 8 or later + +# Configuration + +`pizzaui` provides a powerful interface for editing `pizzawave` configuration (that is, both `pizzaui` and `pizzalib` parameters) through the `Edit->Settings` menu. See [`pizzalib` README](https://github.com/lilhoser/pizzawave/pizzalib) for details on these parameters. + +`pizzaui` also manages its own parameters that are stored in the same `settings.json` file used by `pizzalib` parameters. These parameters are: +* `GroupingStrategy` (default=`Category`): How transcribed calls should be grouped in the UI, by talkgroup field: `Off`=0, `AlphaTag`=1, `Tag`=2, `Description`=3, `Category`=4 +* `ShowAlertMatchesOnly` (default=`false`): If set to `false`, all transcribed calls are shown in the UI; if set to `true`, only calls that trigger an alert will be shown. + +To backup your settings, use `File->Save settings as...`. To load external settings, use `File->Open settings...`. + +# Display Interface + +## Exporting + +All call data and complete transcriptions can be exported to JSON or CSV from the `File->Export..` sub-menus. In headless mode, call data is automatically written to the default working directory as JSON, in the file `calls.json`. + +## Viewing full call transcription + +Hover over the transcription snippet to view a popup window containing the full text of the transcription. + +## Sorting and Filtering + +To sort, click on a column header. To filter by any column, right-click on that column and select a value from the `Filtering` sub-menu. These values are populated from the current dataset being displayed. To clear filters, navigate to the same sub-menu and select `Clear All Filters`. + +The default sort order is call start time, descending. + +## Grouping + +To change how the calls are displayed in groups, select `View->Group by` and choose a talkgroup sub-field. These sub-fields come from the talkgroup CSV you specified in settings. + +The default grouping is by Talkgroup category. + +## Show only calls matching an alert + +By default, `pizzaui` will immediately display all calls in its primary listview control as calls are sent from trunk-recorder. Any call that matches an alert will be highlighted in orange. To show only calls that matched an alert, select `View->Show alert matches only`. + +## Copying data + +To copy rows of data from the display listview, highlight the rows and press CTRL+C. + +# Alerts + +Alerts are driven by rules which are provided by `pizzalib`. Navigate to `Edit->Alerts` to manage your alert rules. ![plot](../docs/screenshot2.png) + +Please see [the `pizzalib` README](https://github.com/lilhoser/pizzawave/pizzalib) for details on how Alerts work. + +# Headless mode + +`pizzaui` can be run without a UI at all, in case you prefer the command line experience or if you want to setup `pizzaui` as a local service. ![plot](../docs/screenshot3.png). + +To operate `pizzaui` in headless mode, simply execute `pizzaui.exe --headless` from a command prompt window. If you don't want to use the default settings file, you can pass `--settings=`. If you don't see any output, ensure that your `pizzalib` settings have the `TraceLevelApp` parameter set to something chatty, like `Verbose`. You can also verify the application is running in headless mode by looking for `pizzaui` in task manager or seeing a port listener in `netstat -an` output. And, of course, if your settings are correct and you have defined some alerts that are being triggered by active calls, you can browse the application's working folder to see output. + +## `pizzaui` as a service + +It's simple to setup `pizzaui` to run as a Windows service. See this [Microsoft Learn page](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/new-service?view=powershell-7.4) for details: + +``` +$params = @{ + Name = "PizzawaveService" + BinaryPathName = '\pizzaui.exe --headless --settings=' + DependsOn = "NetLogon" + DisplayName = "PizzaWave Service" + StartupType = "Automatic" + Description = "Because I like pizza and radio waves." +} +New-Service @params +``` + +# Other + +## Tools + +Pizzawave comes with some helper tools to make your life easier, found in `Tools` menu: +* `Transcription quality` - this tool allows you to listen to a WAV file and compare whisper's transcription of that file +* `Find talkgroups` - this tool helps you find talkgroup data to import into pizzawave +* `Cleanup` - erase all of those extra log files and WAV files \ No newline at end of file diff --git a/pizzaui/Settings.cs b/pizzaui/Settings.cs new file mode 100644 index 0000000..d4da9ae --- /dev/null +++ b/pizzaui/Settings.cs @@ -0,0 +1,109 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using Newtonsoft.Json; + +namespace pizzaui +{ + public class Settings : pizzalib.Settings, IEquatable + { + public MainWindow.CallDisplayGrouping GroupingStrategy; + public bool ShowAlertMatchesOnly; + + public Settings() : base() + { + GroupingStrategy = MainWindow.CallDisplayGrouping.Category; + ShowAlertMatchesOnly = false; + } + + public override bool Equals(object? Other) + { + if (Other == null) + { + return false; + } + var field = Other as Settings; + return Equals(field); + } + + public bool Equals(Settings? Other) + { + if (Other == null) + { + return false; + } + if (GroupingStrategy != Other.GroupingStrategy || + ShowAlertMatchesOnly != Other.ShowAlertMatchesOnly) + { + return false; + } + return base.Equals(Other); + } + + public static bool operator ==(Settings? Settings1, Settings? Settings2) + { + if ((object)Settings1 == null || (object)Settings2 == null) + return Equals(Settings1, Settings2); + return Settings1.Equals(Settings2); + } + + public static bool operator !=(Settings? Settings1, Settings? Settings2) + { + if ((object)Settings1 == null || (object)Settings2 == null) + return !Equals(Settings1, Settings2); + return !(Settings1.Equals(Settings2)); + } + + public override int GetHashCode() + { + return (GroupingStrategy, ShowAlertMatchesOnly).GetHashCode() + base.GetHashCode(); + } + + public override void Validate() + { + base.Validate(); + } + + public void SaveToFile(string Target) + { + string target; + + if (string.IsNullOrEmpty(Target)) + { + target = DefaultSettingsFileLocation; + } + else + { + target = Target; + } + + try + { + Validate(); + var json = JsonConvert.SerializeObject(this, Formatting.Indented); + File.WriteAllText(target, json); + } + catch (Exception ex) + { + throw new Exception($"Could not save the Settings object " + + $"to JSON: {ex.Message}"); + } + } + } +} + diff --git a/pizzaui/SettingsWindow.Designer.cs b/pizzaui/SettingsWindow.Designer.cs new file mode 100644 index 0000000..946637e --- /dev/null +++ b/pizzaui/SettingsWindow.Designer.cs @@ -0,0 +1,455 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +namespace pizzaui +{ + partial class SettingsWindow + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(SettingsWindow)); + saveButton = new Button(); + tableLayoutPanel1 = new TableLayoutPanel(); + settingsTabControl = new TabControl(); + tabPage3 = new TabPage(); + gmailAppPasswordTextbox = new TextBox(); + label12 = new Label(); + gmailUserTextbox = new TextBox(); + label11 = new Label(); + autostartListenerCheckbox = new CheckBox(); + wavOutputLocationTextbox = new TextBox(); + label7 = new Label(); + browseButton = new Button(); + tabPage1 = new TabPage(); + talkgroupCountLabel = new Label(); + label10 = new Label(); + browseButton3 = new Button(); + samplingRateTextbox = new TextBox(); + bitDepthTextbox = new TextBox(); + label6 = new Label(); + label5 = new Label(); + label4 = new Label(); + numChannelsTextbox = new TextBox(); + label2 = new Label(); + listenPortTextbox = new TextBox(); + label1 = new Label(); + tabPage2 = new TabPage(); + browseButton2 = new Button(); + whisperModelFileTextbox = new TextBox(); + label8 = new Label(); + tableLayoutPanel1.SuspendLayout(); + settingsTabControl.SuspendLayout(); + tabPage3.SuspendLayout(); + tabPage1.SuspendLayout(); + tabPage2.SuspendLayout(); + SuspendLayout(); + // + // saveButton + // + saveButton.Anchor = AnchorStyles.None; + saveButton.Location = new Point(301, 820); + saveButton.Margin = new Padding(4, 5, 4, 5); + saveButton.Name = "saveButton"; + saveButton.Size = new Size(164, 84); + saveButton.TabIndex = 5; + saveButton.Text = "Save"; + saveButton.UseVisualStyleBackColor = true; + saveButton.Click += saveButton_Click; + // + // tableLayoutPanel1 + // + tableLayoutPanel1.ColumnCount = 1; + tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); + tableLayoutPanel1.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50F)); + tableLayoutPanel1.Controls.Add(settingsTabControl, 0, 0); + tableLayoutPanel1.Controls.Add(saveButton, 0, 1); + tableLayoutPanel1.Dock = DockStyle.Fill; + tableLayoutPanel1.Location = new Point(0, 0); + tableLayoutPanel1.Name = "tableLayoutPanel1"; + tableLayoutPanel1.RowCount = 2; + tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 89.03509F)); + tableLayoutPanel1.RowStyles.Add(new RowStyle(SizeType.Percent, 10.9649124F)); + tableLayoutPanel1.Size = new Size(766, 912); + tableLayoutPanel1.TabIndex = 9; + // + // settingsTabControl + // + settingsTabControl.Controls.Add(tabPage3); + settingsTabControl.Controls.Add(tabPage1); + settingsTabControl.Controls.Add(tabPage2); + settingsTabControl.Dock = DockStyle.Fill; + settingsTabControl.Location = new Point(3, 3); + settingsTabControl.Name = "settingsTabControl"; + settingsTabControl.SelectedIndex = 0; + settingsTabControl.Size = new Size(760, 806); + settingsTabControl.TabIndex = 9; + // + // tabPage3 + // + tabPage3.Controls.Add(gmailAppPasswordTextbox); + tabPage3.Controls.Add(label12); + tabPage3.Controls.Add(gmailUserTextbox); + tabPage3.Controls.Add(label11); + tabPage3.Controls.Add(autostartListenerCheckbox); + tabPage3.Controls.Add(wavOutputLocationTextbox); + tabPage3.Controls.Add(label7); + tabPage3.Controls.Add(browseButton); + tabPage3.Location = new Point(4, 34); + tabPage3.Name = "tabPage3"; + tabPage3.Size = new Size(752, 768); + tabPage3.TabIndex = 2; + tabPage3.Text = "PizzaWave"; + tabPage3.UseVisualStyleBackColor = true; + // + // gmailAppPasswordTextbox + // + gmailAppPasswordTextbox.Location = new Point(196, 118); + gmailAppPasswordTextbox.Margin = new Padding(4, 5, 4, 5); + gmailAppPasswordTextbox.Name = "gmailAppPasswordTextbox"; + gmailAppPasswordTextbox.Size = new Size(373, 31); + gmailAppPasswordTextbox.TabIndex = 6; + // + // label12 + // + label12.AutoSize = true; + label12.Location = new Point(7, 118); + label12.Margin = new Padding(4, 0, 4, 0); + label12.Name = "label12"; + label12.Size = new Size(179, 25); + label12.TabIndex = 28; + label12.Text = "Gmail app password:"; + // + // gmailUserTextbox + // + gmailUserTextbox.Location = new Point(196, 63); + gmailUserTextbox.Margin = new Padding(4, 5, 4, 5); + gmailUserTextbox.Name = "gmailUserTextbox"; + gmailUserTextbox.Size = new Size(373, 31); + gmailUserTextbox.TabIndex = 5; + // + // label11 + // + label11.AutoSize = true; + label11.Location = new Point(7, 63); + label11.Margin = new Padding(4, 0, 4, 0); + label11.Name = "label11"; + label11.Size = new Size(148, 25); + label11.TabIndex = 26; + label11.Text = "Gmail user name:"; + // + // autostartListenerCheckbox + // + autostartListenerCheckbox.AutoSize = true; + autostartListenerCheckbox.Checked = true; + autostartListenerCheckbox.CheckState = CheckState.Checked; + autostartListenerCheckbox.Location = new Point(7, 169); + autostartListenerCheckbox.Name = "autostartListenerCheckbox"; + autostartListenerCheckbox.Size = new Size(164, 29); + autostartListenerCheckbox.TabIndex = 25; + autostartListenerCheckbox.Text = "Autostart server"; + autostartListenerCheckbox.UseVisualStyleBackColor = true; + // + // wavOutputLocationTextbox + // + wavOutputLocationTextbox.Location = new Point(196, 13); + wavOutputLocationTextbox.Margin = new Padding(4, 5, 4, 5); + wavOutputLocationTextbox.Name = "wavOutputLocationTextbox"; + wavOutputLocationTextbox.Size = new Size(373, 31); + wavOutputLocationTextbox.TabIndex = 2; + // + // label7 + // + label7.AutoSize = true; + label7.Location = new Point(7, 13); + label7.Margin = new Padding(4, 0, 4, 0); + label7.Name = "label7"; + label7.Size = new Size(161, 25); + label7.TabIndex = 21; + label7.Text = "Save MP3s to disk:"; + // + // browseButton + // + browseButton.Location = new Point(577, 10); + browseButton.Margin = new Padding(4, 5, 4, 5); + browseButton.Name = "browseButton"; + browseButton.Size = new Size(94, 36); + browseButton.TabIndex = 3; + browseButton.Text = "Browse..."; + browseButton.UseVisualStyleBackColor = true; + browseButton.Click += browseButton_Click; + // + // tabPage1 + // + tabPage1.Controls.Add(talkgroupCountLabel); + tabPage1.Controls.Add(label10); + tabPage1.Controls.Add(browseButton3); + tabPage1.Controls.Add(samplingRateTextbox); + tabPage1.Controls.Add(bitDepthTextbox); + tabPage1.Controls.Add(label6); + tabPage1.Controls.Add(label5); + tabPage1.Controls.Add(label4); + tabPage1.Controls.Add(numChannelsTextbox); + tabPage1.Controls.Add(label2); + tabPage1.Controls.Add(listenPortTextbox); + tabPage1.Controls.Add(label1); + tabPage1.Location = new Point(4, 34); + tabPage1.Name = "tabPage1"; + tabPage1.Padding = new Padding(3); + tabPage1.Size = new Size(752, 768); + tabPage1.TabIndex = 0; + tabPage1.Text = "Trunk Recorder"; + tabPage1.UseVisualStyleBackColor = true; + // + // talkgroupCountLabel + // + talkgroupCountLabel.AutoSize = true; + talkgroupCountLabel.Location = new Point(132, 135); + talkgroupCountLabel.Margin = new Padding(4, 0, 4, 0); + talkgroupCountLabel.Name = "talkgroupCountLabel"; + talkgroupCountLabel.Size = new Size(149, 25); + talkgroupCountLabel.TabIndex = 25; + talkgroupCountLabel.Text = "(talkgroup count)"; + // + // label10 + // + label10.AutoSize = true; + label10.Location = new Point(7, 132); + label10.Margin = new Padding(4, 0, 4, 0); + label10.Name = "label10"; + label10.Size = new Size(102, 25); + label10.TabIndex = 24; + label10.Text = "Talkgroups:"; + // + // browseButton3 + // + browseButton3.Location = new Point(390, 129); + browseButton3.Margin = new Padding(4, 5, 4, 5); + browseButton3.Name = "browseButton3"; + browseButton3.Size = new Size(94, 36); + browseButton3.TabIndex = 5; + browseButton3.Text = "Load..."; + browseButton3.UseVisualStyleBackColor = true; + browseButton3.Click += browseButton3_Click; + // + // samplingRateTextbox + // + samplingRateTextbox.Location = new Point(587, 71); + samplingRateTextbox.Margin = new Padding(4, 5, 4, 5); + samplingRateTextbox.Name = "samplingRateTextbox"; + samplingRateTextbox.Size = new Size(91, 31); + samplingRateTextbox.TabIndex = 4; + samplingRateTextbox.Text = "16000"; + // + // bitDepthTextbox + // + bitDepthTextbox.Location = new Point(390, 71); + bitDepthTextbox.Margin = new Padding(4, 5, 4, 5); + bitDepthTextbox.Name = "bitDepthTextbox"; + bitDepthTextbox.Size = new Size(45, 31); + bitDepthTextbox.TabIndex = 3; + bitDepthTextbox.Text = "16"; + // + // label6 + // + label6.AutoSize = true; + label6.Location = new Point(453, 74); + label6.Margin = new Padding(4, 0, 4, 0); + label6.Name = "label6"; + label6.Size = new Size(126, 25); + label6.TabIndex = 15; + label6.Text = "Sampling rate:"; + // + // label5 + // + label5.AutoSize = true; + label5.Location = new Point(132, 71); + label5.Margin = new Padding(4, 0, 4, 0); + label5.Name = "label5"; + label5.Size = new Size(87, 25); + label5.TabIndex = 14; + label5.Text = "Channels:"; + // + // label4 + // + label4.AutoSize = true; + label4.Location = new Point(294, 71); + label4.Margin = new Padding(4, 0, 4, 0); + label4.Name = "label4"; + label4.Size = new Size(88, 25); + label4.TabIndex = 13; + label4.Text = "Bit depth:"; + // + // numChannelsTextbox + // + numChannelsTextbox.Location = new Point(227, 71); + numChannelsTextbox.Margin = new Padding(4, 5, 4, 5); + numChannelsTextbox.Name = "numChannelsTextbox"; + numChannelsTextbox.Size = new Size(45, 31); + numChannelsTextbox.TabIndex = 2; + numChannelsTextbox.Text = "1"; + // + // label2 + // + label2.AutoSize = true; + label2.Location = new Point(7, 71); + label2.Margin = new Padding(4, 0, 4, 0); + label2.Name = "label2"; + label2.Size = new Size(113, 25); + label2.TabIndex = 11; + label2.Text = "Analog data:"; + // + // listenPortTextbox + // + listenPortTextbox.Location = new Point(141, 10); + listenPortTextbox.Margin = new Padding(4, 5, 4, 5); + listenPortTextbox.Name = "listenPortTextbox"; + listenPortTextbox.Size = new Size(78, 31); + listenPortTextbox.TabIndex = 1; + listenPortTextbox.Text = "9123"; + // + // label1 + // + label1.AutoSize = true; + label1.Location = new Point(7, 13); + label1.Margin = new Padding(4, 0, 4, 0); + label1.Name = "label1"; + label1.Size = new Size(126, 25); + label1.TabIndex = 8; + label1.Text = "Listen on port:"; + // + // tabPage2 + // + tabPage2.Controls.Add(browseButton2); + tabPage2.Controls.Add(whisperModelFileTextbox); + tabPage2.Controls.Add(label8); + tabPage2.Location = new Point(4, 34); + tabPage2.Name = "tabPage2"; + tabPage2.Padding = new Padding(3); + tabPage2.Size = new Size(752, 768); + tabPage2.TabIndex = 1; + tabPage2.Text = "Whisper.net"; + tabPage2.UseVisualStyleBackColor = true; + // + // browseButton2 + // + browseButton2.Location = new Point(583, 8); + browseButton2.Margin = new Padding(4, 5, 4, 5); + browseButton2.Name = "browseButton2"; + browseButton2.Size = new Size(94, 36); + browseButton2.TabIndex = 21; + browseButton2.Text = "Browse..."; + browseButton2.UseVisualStyleBackColor = true; + browseButton2.Click += browseButton2_Click; + // + // whisperModelFileTextbox + // + whisperModelFileTextbox.Location = new Point(228, 12); + whisperModelFileTextbox.Margin = new Padding(4, 5, 4, 5); + whisperModelFileTextbox.Name = "whisperModelFileTextbox"; + whisperModelFileTextbox.Size = new Size(347, 31); + whisperModelFileTextbox.TabIndex = 1; + // + // label8 + // + label8.AutoSize = true; + label8.Location = new Point(7, 12); + label8.Margin = new Padding(4, 0, 4, 0); + label8.Name = "label8"; + label8.Size = new Size(222, 25); + label8.TabIndex = 10; + label8.Text = "Whisper model file (ggml):"; + // + // SettingsWindow + // + AutoScaleDimensions = new SizeF(10F, 25F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(766, 912); + Controls.Add(tableLayoutPanel1); + DoubleBuffered = true; + Icon = (Icon)resources.GetObject("$this.Icon"); + Margin = new Padding(4, 5, 4, 5); + Name = "SettingsWindow"; + StartPosition = FormStartPosition.CenterParent; + Text = "Settings"; + tableLayoutPanel1.ResumeLayout(false); + settingsTabControl.ResumeLayout(false); + tabPage3.ResumeLayout(false); + tabPage3.PerformLayout(); + tabPage1.ResumeLayout(false); + tabPage1.PerformLayout(); + tabPage2.ResumeLayout(false); + tabPage2.PerformLayout(); + ResumeLayout(false); + } + + #endregion + private System.Windows.Forms.Button saveButton; + private TableLayoutPanel tableLayoutPanel1; + private TabControl settingsTabControl; + private TabPage tabPage1; + private TextBox numChannelsTextbox; + private Label label2; + private TextBox listenPortTextbox; + private Label label1; + private TabPage tabPage2; + private TabPage tabPage3; + private Label label6; + private Label label5; + private Label label4; + private TextBox samplingRateTextbox; + private TextBox bitDepthTextbox; + private TextBox wavOutputLocationTextbox; + private Label label7; + private Button browseButton; + private Button browseButton2; + private TextBox whisperModelFileTextbox; + private Label label8; + private Label label10; + private Button browseButton3; + private Label talkgroupCountLabel; + private CheckBox autostartListenerCheckbox; + private TextBox gmailUserTextbox; + private Label label11; + private TextBox gmailAppPasswordTextbox; + private Label label12; + } +} \ No newline at end of file diff --git a/pizzaui/SettingsWindow.cs b/pizzaui/SettingsWindow.cs new file mode 100644 index 0000000..28f6997 --- /dev/null +++ b/pizzaui/SettingsWindow.cs @@ -0,0 +1,220 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using pizzalib; + +namespace pizzaui +{ + public partial class SettingsWindow : Form + { + private Settings m_OriginalSettings; + private List m_LoadedTalkgroups; + private bool m_SaveDisabled; + public Settings m_UpdatedSettings; + + public SettingsWindow(Settings CurrentSettings, bool SaveDisabled) + { + InitializeComponent(); + + m_SaveDisabled = SaveDisabled; + m_UpdatedSettings = new Settings(); + + if (m_SaveDisabled) + { + foreach (var tab in settingsTabControl.TabPages) + { + foreach (var control in ((TabPage)tab).Controls) + { + ((Control)control).Enabled = false; + } + } + saveButton.Enabled = false; + } + + m_LoadedTalkgroups = CurrentSettings.talkgroups; + + if (m_LoadedTalkgroups != null) + { + talkgroupCountLabel.Text = $"Loaded {m_LoadedTalkgroups.Count} talkgroups"; + } + + // + // Application settings + // + wavOutputLocationTextbox.Text = CurrentSettings.WavFileLocation; + autostartListenerCheckbox.Checked = CurrentSettings.AutostartListener; + gmailUserTextbox.Text = CurrentSettings.gmailUser; + if (!string.IsNullOrEmpty(CurrentSettings.gmailPassword)) + { + gmailAppPasswordTextbox.Text = CurrentSettings.gmailPassword; + } + + // + // TrunkRecorder settings + // + listenPortTextbox.Text = $"{CurrentSettings.listenPort}"; + samplingRateTextbox.Text = $"{CurrentSettings.analogSamplingRate}"; + numChannelsTextbox.Text = $"{CurrentSettings.analogChannels}"; + bitDepthTextbox.Text = $"{CurrentSettings.analogBitDepth}"; + // + // Whisper.net settings + // + whisperModelFileTextbox.Text = CurrentSettings.whisperModelFile; + + m_OriginalSettings = CurrentSettings; + } + + private Settings GetSettings() + { + var settings = new Settings(); + + // + // Important: we need to preserve settings that are not managed from + // SettingsWindow, or they'll be lost. See settings.cs for details. + // + settings.Alerts = m_OriginalSettings.Alerts; + settings.TraceLevelApp = m_OriginalSettings.TraceLevelApp; + settings.GroupingStrategy = m_OriginalSettings.GroupingStrategy; + settings.ShowAlertMatchesOnly = m_OriginalSettings.ShowAlertMatchesOnly; + + // + // Application settings + // + settings.WavFileLocation = wavOutputLocationTextbox.Text; + settings.AutostartListener = autostartListenerCheckbox.Checked; + settings.gmailUser = gmailUserTextbox.Text; + settings.gmailPassword = gmailAppPasswordTextbox.Text; + // + // TrunkRecorder settings + // + if (!int.TryParse(listenPortTextbox.Text, out settings.listenPort)) + { + throw new Exception("Invalid listen port"); + } + if (!int.TryParse(samplingRateTextbox.Text, out settings.analogSamplingRate)) + { + throw new Exception("Invalid sampling rate"); + } + if (!int.TryParse(numChannelsTextbox.Text, out settings.analogChannels)) + { + throw new Exception("Invalid analog channels"); + } + if (!int.TryParse(bitDepthTextbox.Text, out settings.analogBitDepth)) + { + throw new Exception("Invalid bit depth"); + } + settings.talkgroups = m_LoadedTalkgroups; + // + // Whisper.net settings + // + settings.whisperModelFile = whisperModelFileTextbox.Text; + return settings; + } + + public bool HasNewSettings() + { + try + { + return !(GetSettings() == m_OriginalSettings); + } + catch + { + return true; + } + } + + private void saveButton_Click(object sender, EventArgs e) + { + if (!HasNewSettings()) + { + DialogResult = DialogResult.Cancel; + Close(); + return; + } + + try + { + var newSettings = GetSettings(); + newSettings.SaveToFile(string.Empty); + m_UpdatedSettings = newSettings; + } + catch (Exception ex) + { + MessageBox.Show($"{ex.Message}"); + return; + } + DialogResult = DialogResult.OK; + Close(); + } + + private void browseButton_Click(object sender, EventArgs e) + { + FolderBrowserDialog dialog = new FolderBrowserDialog(); + dialog.RootFolder = Environment.SpecialFolder.UserProfile; + if (dialog.ShowDialog() != DialogResult.OK) + { + return; + } + wavOutputLocationTextbox.Text = dialog.SelectedPath; + } + + private void browseButton2_Click(object sender, EventArgs e) + { + OpenFileDialog dialog = new OpenFileDialog(); + dialog.CheckFileExists = true; + dialog.CheckPathExists = true; + dialog.Multiselect = false; + + if (dialog.ShowDialog() != DialogResult.OK) + { + return; + } + + whisperModelFileTextbox.Text = dialog.FileName; + } + + private void browseButton3_Click(object sender, EventArgs e) + { + OpenFileDialog dialog = new OpenFileDialog(); + dialog.CheckFileExists = true; + dialog.CheckPathExists = true; + dialog.Multiselect = false; + + if (dialog.ShowDialog() != DialogResult.OK) + { + return; + } + + var location = dialog.FileName; + try + { + var tgs = TalkgroupHelper.GetTalkgroupsFromCsv(location); + if (tgs.Count == 0) + { + throw new Exception($"No data in file"); + } + m_LoadedTalkgroups = tgs; + talkgroupCountLabel.Text = $"Loaded {m_LoadedTalkgroups.Count} talkgroups"; + } + catch (Exception ex) + { + MessageBox.Show($"Unable to parse talkgroup CSV '{location}': {ex.Message}"); + } + } + } +} diff --git a/pizzaui/SettingsWindow.resx b/pizzaui/SettingsWindow.resx new file mode 100644 index 0000000..8b82f69 --- /dev/null +++ b/pizzaui/SettingsWindow.resx @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + AAABAAEAICAAAAEAIACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAABMLAAATCwAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJSQmIh4dIXckIycNAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAA1NTcQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKikrCh0cH4dITln5f4yg/zs7 + P5MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAALSwtHyYrMGpJSEgQLy4vKzY3NgMAAAAAAAAAACAeIj8xOEHcfpWv/42l + vv9jo7r/OldizgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmJytvMoWg+Tc1NjMAAAAAPDw8BSclJw0fHiKWS1xw/nSS + rP9ge4j/WLra/13X/v8zTVmuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEhJTHM9coT7VFJTDwAAAAAfHiJLLjlE4mOH + p/9niqb/Wp63/0Ot0v9BrND/Xsnr/x0fJGUvMDEOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6OToFJSMlOgAAAAAfHyAxKSksWzZkduomJSgZHh4jqTtS + af9vpM7/S4Ce/zOfwv83uuT/QdD//1DR+v9ZnLP/KSktHCgmKBcgHyE7AAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAGxsawMAAAAAAAAAAEFAQoQoJihUMTEyDQAAAAApS1mEK2h+/jBC + U/FgmcT/Z6DM/0CVt/8znsL/N7fi/zvL+v9D0Pz/VtX+/1l+jdMAAAAALCssDB8eIAcAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhHyIuK2By9CQjJiwfHyEUR0VHDCYkJTkrKiwKHh0hfyUy + Pe5Jdpn/Zaja/1GNr/81ocX/NKTK/zvE8v87yPf/NrLc/zmp0f9n0PP/P0tUjikpKShAQEEFAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB0bH2M5j6z/IyIlPjw8PQsAAAAAIyIlRCIr + NdVFeKD/ZbHq/1WJsP9Lla//Qcr4/zzD8P8+yvr/OsDt/z3O//86xPL/NZS5/2eqwf8eICZDAAAAAAAA + AAA5OToDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACYmJx4AAAAAHRwfcjWewP8mJScwIB4hVyAl + Lck1Wnf/W6zo/02Fsv9FcIX/Tr3h/zvE8v83sdr/PcLu/z3C8P8vjrD/LYao/zi44v9BuuH/aIiW5D4/ + RANYWVgHODk5CikpKhoAAAAAAAAAAAAAAAAAAAAAAAAAAE5OTgIzNDUMAAAAAAAAAAApVmeJNqvS/yI+ + SvEpQFD/TI7C/0yTx/85Y33/UZes/0fI8v83uuT/OLLc/z/G9P83cZH/PU2G/z5YlP84U43/MF6A/2nP + 8P9OUViGUFBRBQAAAAAuLi8rAAAAAAAAAAAAAAAAAAAAAC8vMAsAAAAAIyMkHQAAAAAkIyUoHR0fgShb + bug8vOf/PM3+/zq+6v9Pttb/XcDe/1zS9f8ymr7/NabN/z3G9f8/zv//M3KS/z9Vof8wXoX/LVKQ/yhT + hP8rVKr/R1Rk+R8hKB4xMTIYKyosKyoqKwwoJyktAAAAAAAAAAAAAAAAAAAAACcmKA8dHCB7JjE60TZS + af8qO0r/NnKG/0NpeP9zxuH/Pcj2/zzH9f8yncD/NKDE/z7K+P8/y/v/O7zo/0DO//8vWoz/MlWk/ylH + fP8yTKz/KViE/y1crP8fKUCVc3FyAQAAAAAlIyZELiwuCycmJ0EAAAAAAAAAAAAAAAAlIyYsJC4440Z1 + mP9Uk73/RXSW/z9ogv95oLD/Ztf+/z7N/f84s9z/NqrQ/ziz3P8qaYX/Iz5V/yM7VP8sa4b/Pb/s/zWU + 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 + /iPA/GAPwP8DH8Bx+D/geAB/+H8B//////8= + + + \ No newline at end of file diff --git a/pizzaui/TraceLogger.cs b/pizzaui/TraceLogger.cs new file mode 100644 index 0000000..b70fda2 --- /dev/null +++ b/pizzaui/TraceLogger.cs @@ -0,0 +1,178 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; + +namespace pizzaui +{ + using static Settings; + + public static class TraceLogger + { + public static readonly string m_TraceFileDir = Path.Combine(new string[] {pizzalib.Settings.DefaultWorkingDirectory, "Logs"}); + private static string m_Location = Path.Combine(new string[] { m_TraceFileDir, + $"pizzawave-{DateTime.Now.ToString("yyyy-MM-dd-HHmmss")}.txt"}); + private static TextWriterTraceListener m_TextWriterTraceListener = + new TextWriterTraceListener(m_Location, "pizzawaveTextWriterListener"); + private static ConsoleTraceListener m_ConsoleTraceListener = new ConsoleTraceListener(); + private static SourceSwitch m_Switch = + new SourceSwitch("pizzawaveSwitch", "Verbose"); + private static TraceSource[] Sources = { + new TraceSource("MainWindow", SourceLevels.Verbose), + new TraceSource("StreamServer", SourceLevels.Verbose), + new TraceSource("WavStreamData", SourceLevels.Verbose), + new TraceSource("Settings", SourceLevels.Verbose), + new TraceSource("Whisper", SourceLevels.Verbose), + new TraceSource("Alerts", SourceLevels.Verbose), + new TraceSource("Utilities", SourceLevels.Verbose), + new TraceSource("Headless", SourceLevels.Verbose), + }; + + public enum TraceLoggerType + { + MainWindow, + StreamServer, + WavStreamData, + Settings, + Whisper, + Alerts, + Utilities, + Headless, + Max + } + + public static void Initialize(bool RedirectToStdout = false) + { + System.Diagnostics.Trace.AutoFlush = true; + foreach (var source in Sources) + { + source.Listeners.Add(m_TextWriterTraceListener); + source.Switch = m_Switch; + if (RedirectToStdout) + { + source.Listeners.Add(m_ConsoleTraceListener); + } + } + + if (Directory.Exists(pizzalib.Settings.DefaultWorkingDirectory)) + { + if (!Directory.Exists(m_TraceFileDir)) + { + try + { + Directory.CreateDirectory(m_TraceFileDir); + } + catch (Exception) // swallow + { + } + } + } + } + + public static void Shutdown() + { + m_TextWriterTraceListener.Close(); + m_ConsoleTraceListener.Close(); + } + + public static void SetLevel(SourceLevels Level) + { + m_Switch.Level = Level; + } + + public static void Trace(TraceLoggerType Type, TraceEventType EventType, string Message) + { + if (Type >= TraceLoggerType.Max) + { + throw new Exception("Invalid logger type"); + } + using (GetColorContext(EventType)) + { + Sources[(int)Type].TraceEvent(EventType, 1, $"{DateTime.Now:M/d/yyyy h:mm tt}: {Message}"); + } + } + + public static void OpenTraceLog() + { + Utilities.LaunchFile(m_Location); + } + + private static ColorContext GetColorContext(TraceEventType eventType) + { + switch (eventType) + { + case TraceEventType.Verbose: + return new ColorContext(ConsoleColor.DarkGray); + case TraceEventType.Information: + return new ColorContext(ConsoleColor.Gray); + case TraceEventType.Critical: + return new ColorContext(ConsoleColor.DarkRed); + case TraceEventType.Error: + return new ColorContext(ConsoleColor.Red); + case TraceEventType.Warning: + return new ColorContext(ConsoleColor.Yellow); + case TraceEventType.Start: + return new ColorContext(ConsoleColor.DarkGreen); + case TraceEventType.Stop: + return new ColorContext(ConsoleColor.DarkMagenta); + case TraceEventType.Transfer: + return new ColorContext(ConsoleColor.DarkYellow); + default: + return new ColorContext(); + } + } + } + + internal sealed class ColorContext : IDisposable + { + private readonly ConsoleColor previousBackgroundColor; + private readonly ConsoleColor previousForegroundColor; + private bool isDisposed; + + public ColorContext() + : this(Console.ForegroundColor, Console.BackgroundColor) + { + } + + public ColorContext(ConsoleColor foregroundColor) + : this(foregroundColor, Console.BackgroundColor) + { + } + + public ColorContext(ConsoleColor foregroundColor, ConsoleColor backgroundColor) + { + this.isDisposed = false; + this.previousForegroundColor = Console.ForegroundColor; + this.previousBackgroundColor = Console.BackgroundColor; + Console.ForegroundColor = foregroundColor; + Console.BackgroundColor = backgroundColor; + } + + public void Dispose() + { + if (this.isDisposed) + { + return; + } + + Console.ForegroundColor = this.previousForegroundColor; + Console.BackgroundColor = this.previousBackgroundColor; + this.isDisposed = true; + } + } +} diff --git a/pizzaui/Utilities.cs b/pizzaui/Utilities.cs new file mode 100644 index 0000000..2b8f2c5 --- /dev/null +++ b/pizzaui/Utilities.cs @@ -0,0 +1,79 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you under the Apache License, Version 2.0 (the +"License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, either express or implied. See the License for the +specific language governing permissions and limitations +under the License. +*/ +using System.Diagnostics; +using System.Text; + +namespace pizzaui +{ + internal static class Utilities + { + + public static void LaunchFile(string FileName) + { + if (!File.Exists(FileName)) + { + return; + } + var psi = new ProcessStartInfo(); + psi.FileName = FileName; + psi.UseShellExecute = true; + Process.Start(psi); + } + + public static void LaunchBrowser(string Url) + { + var psi = new ProcessStartInfo(Url); + psi.UseShellExecute = true; + Process.Start(psi); + } + + public static string Wordwrap(string LongString, int MaxLineLength) + { + if (LongString.Length < MaxLineLength) + { + return LongString; + } + var lineLength = (int)Math.Sqrt((double)LongString.Length) * 2; + var sb = new StringBuilder(); + var currentLinePosition = 0; + for (int textIndex = 0; textIndex < LongString.Length; textIndex++) + { + if (currentLinePosition >= lineLength && char.IsWhiteSpace(LongString[textIndex])) + { + sb.Append(Environment.NewLine); + currentLinePosition = 0; + } + if (currentLinePosition == 0) + { + while (textIndex < LongString.Length && char.IsWhiteSpace(LongString[textIndex])) + { + textIndex++; + } + } + + if (textIndex < LongString.Length) + { + sb.Append(LongString[textIndex]); + } + currentLinePosition++; + } + return sb.ToString(); + } + } +} diff --git a/pizzaui/images/logo-med.png b/pizzaui/images/logo-med.png new file mode 100644 index 0000000..69ca11b Binary files /dev/null and b/pizzaui/images/logo-med.png differ diff --git a/pizzaui/images/logo-small.ico b/pizzaui/images/logo-small.ico new file mode 100644 index 0000000..c4811d9 Binary files /dev/null and b/pizzaui/images/logo-small.ico differ diff --git a/pizzaui/images/logo-small.png b/pizzaui/images/logo-small.png new file mode 100644 index 0000000..bcb8cd2 Binary files /dev/null and b/pizzaui/images/logo-small.png differ diff --git a/pizzaui/images/logo.png b/pizzaui/images/logo.png new file mode 100644 index 0000000..4dd000b Binary files /dev/null and b/pizzaui/images/logo.png differ diff --git a/pizzaui/pizzaui.csproj b/pizzaui/pizzaui.csproj new file mode 100644 index 0000000..b5b87fc --- /dev/null +++ b/pizzaui/pizzaui.csproj @@ -0,0 +1,33 @@ + + + + WinExe + net8.0-windows + enable + true + enable + + + + + + + + + + + + + + Form + + + Form + + + + Form + + + + \ No newline at end of file diff --git a/pizzawave.sln b/pizzawave.sln new file mode 100644 index 0000000..a0ed8da --- /dev/null +++ b/pizzawave.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.9.34607.119 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "pizzalib", "pizzalib\pizzalib.csproj", "{2FCB885E-2BAA-47B4-8D25-9CF8B15D6562}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "pizzacmd", "pizzacmd\pizzacmd.csproj", "{DA591984-A4F8-46B5-A15C-7F84E7515024}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "pizzaui", "pizzaui\pizzaui.csproj", "{80994598-1AA9-42DD-A5B6-E2A8A3138D62}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2FCB885E-2BAA-47B4-8D25-9CF8B15D6562}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2FCB885E-2BAA-47B4-8D25-9CF8B15D6562}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2FCB885E-2BAA-47B4-8D25-9CF8B15D6562}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2FCB885E-2BAA-47B4-8D25-9CF8B15D6562}.Release|Any CPU.Build.0 = Release|Any CPU + {DA591984-A4F8-46B5-A15C-7F84E7515024}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA591984-A4F8-46B5-A15C-7F84E7515024}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA591984-A4F8-46B5-A15C-7F84E7515024}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA591984-A4F8-46B5-A15C-7F84E7515024}.Release|Any CPU.Build.0 = Release|Any CPU + {80994598-1AA9-42DD-A5B6-E2A8A3138D62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80994598-1AA9-42DD-A5B6-E2A8A3138D62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80994598-1AA9-42DD-A5B6-E2A8A3138D62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80994598-1AA9-42DD-A5B6-E2A8A3138D62}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {891A6C74-2F00-4A22-82EF-5B82E288F318} + EndGlobalSection +EndGlobal