diff --git a/OrcanodeMonitor/Core/Fetcher.cs b/OrcanodeMonitor/Core/Fetcher.cs index 3ebdd08..03d2385 100644 --- a/OrcanodeMonitor/Core/Fetcher.cs +++ b/OrcanodeMonitor/Core/Fetcher.cs @@ -438,9 +438,9 @@ public async static Task UpdateDataplicityDataAsync(OrcanodeMonitorContext conte /// null on error, or JsonElement on success private async static Task GetOrcasoundDataAsync(OrcanodeMonitorContext context, string site, ILogger logger) { + string url = "https://" + site + _orcasoundFeedsUrlPath; try { - string url = "https://" + site + _orcasoundFeedsUrlPath; string json = await _httpClient.GetStringAsync(url); if (json.IsNullOrEmpty()) { diff --git a/OrcanodeMonitor/Core/FfmpegCoreAnalyzer.cs b/OrcanodeMonitor/Core/FfmpegCoreAnalyzer.cs index b59c5a5..7460ea2 100644 --- a/OrcanodeMonitor/Core/FfmpegCoreAnalyzer.cs +++ b/OrcanodeMonitor/Core/FfmpegCoreAnalyzer.cs @@ -2,242 +2,11 @@ // SPDX-License-Identifier: MIT using FFMpegCore; using FFMpegCore.Pipes; -using MathNet.Numerics.IntegralTransforms; using NAudio.Wave; using OrcanodeMonitor.Models; -using System.Diagnostics; -using System.Numerics; namespace OrcanodeMonitor.Core { - public class FrequencyInfo - { - public FrequencyInfo(float[] data, int sampleRate, int channels, OrcanodeOnlineStatus oldStatus) - { - FrequencyMagnitudes = ComputeFrequencyMagnitudes(data, sampleRate, channels); - Status = GetStatus(oldStatus); - } - - private static Dictionary ComputeFrequencyMagnitudes(float[] data, int sampleRate, int channels) - { - var result = new Dictionary(); - int n = data.Length / channels; - - // Create an array of complex data for each channel. - Complex[][] complexData = new Complex[channels][]; - for (int ch = 0; ch < channels; ch++) - { - complexData[ch] = new Complex[n]; - } - - // Populate the complex arrays with channel data. - for (int i = 0; i < n; i++) - { - for (int ch = 0; ch < channels; ch++) - { - complexData[ch][i] = new Complex(data[i * channels + ch], 0); - } - } - - // Perform Fourier transform for each channel. - var channelResults = new List>(); - for (int ch = 0; ch < channels; ch++) - { - Fourier.Forward(complexData[ch], FourierOptions.Matlab); - var channelResult = new Dictionary(); - for (int i = 0; i < n / 2; i++) - { - double magnitude = complexData[ch][i].Magnitude; - double frequency = (((double)i) * sampleRate) / n; - channelResult[frequency] = magnitude; - } - channelResults.Add(channelResult); - } - - // Combine results from all channels. - foreach (var channelResult in channelResults) - { - foreach (var kvp in channelResult) - { - if (!result.ContainsKey(kvp.Key)) - { - result[kvp.Key] = 0; - } - if (result[kvp.Key] < kvp.Value) - { - result[kvp.Key] = kvp.Value; - } - } - } - - return result; - } - - // We consider anything above this average magnitude as not silence. - const double _defaultMaxSilenceMagnitude = 20.0; - public static double MaxSilenceMagnitude - { - get - { - string? maxSilenceMagnitudeString = Environment.GetEnvironmentVariable("ORCASOUND_MAX_SILENCE_MAGNITUDE"); - double maxSilenceMagnitude = double.TryParse(maxSilenceMagnitudeString, out var magnitude) ? magnitude : _defaultMaxSilenceMagnitude; - return maxSilenceMagnitude; - } - } - - // We consider anything below this average magnitude as silence. - const double _defaultMinNoiseMagnitude = 15.0; - public static double MinNoiseMagnitude - { - get - { - string? minNoiseMagnitudeString = Environment.GetEnvironmentVariable("ORCASOUND_MIN_NOISE_MAGNITUDE"); - double minNoiseMagnitude = double.TryParse(minNoiseMagnitudeString, out var magnitude) ? magnitude : _defaultMinNoiseMagnitude; - return minNoiseMagnitude; - } - } - - // Minimum ratio of magnitude outside the hum range to magnitude - // within the hum range. So far the max in a known-unintelligible - // sample is 53% and the min in a known-good sample is 114%. - const double _defaultMinSignalPercent = 100; - private static double MinSignalRatio - { - get - { - string? minSignalPercentString = Environment.GetEnvironmentVariable("ORCASOUND_MIN_INTELLIGIBLE_SIGNAL_PERCENT"); - double minSignalPercent = double.TryParse(minSignalPercentString, out var percent) ? percent : _defaultMinSignalPercent; - return minSignalPercent / 100.0; - } - } - - public Dictionary FrequencyMagnitudes { get; } - public OrcanodeOnlineStatus Status { get; } - - /// - /// URL at which the original audio sample can be found. - /// - public string AudioSampleUrl { get; set; } = string.Empty; - - public double MaxMagnitude => FrequencyMagnitudes.Values.Max(); - - // Microphone audio hum typically falls within the 50 Hz or 60 Hz - // range. This hum is often caused by electrical interference from - // power lines and other electronic devices. - const double HumFrequency1 = 50.0; // Hz - const double HumFrequency2 = 60.0; // Hz - private static bool IsHumFrequency(double frequency, double humFrequency) - { - Debug.Assert(frequency >= 0.0); - Debug.Assert(humFrequency >= 0.0); - const double tolerance = 1.0; - double remainder = frequency % humFrequency; - return (remainder < tolerance || remainder > (humFrequency - tolerance)); - } - - public static bool IsHumFrequency(double frequency) => IsHumFrequency(frequency, HumFrequency1) || IsHumFrequency(frequency, HumFrequency2); - - /// - /// Find the maximum magnitude outside the audio hum range. - /// - /// Magnitude - public double GetMaxNonHumMagnitude() - { - double maxNonHumMagnitude = 0; - foreach (var pair in FrequencyMagnitudes) - { - double frequency = pair.Key; - double magnitude = pair.Value; - if (!IsHumFrequency(frequency)) - { - if (maxNonHumMagnitude < magnitude) - { - maxNonHumMagnitude = magnitude; - } - } - } - return maxNonHumMagnitude; - } - - /// - /// Find the total magnitude outside the audio hum range. - /// - /// Magnitude - public double GetTotalNonHumMagnitude() - { - double totalNonHumMagnitude = 0; - foreach (var pair in FrequencyMagnitudes) - { - double frequency = pair.Key; - double magnitude = pair.Value; - if (!IsHumFrequency(frequency)) - { - if (magnitude > MinNoiseMagnitude) - { - totalNonHumMagnitude += magnitude; - } - } - } - return totalNonHumMagnitude; - } - - /// - /// Find the total magnitude of the audio hum range. - /// - /// Magnitude - public double GetTotalHumMagnitude() - { - double totalHumMagnitude = 0; - foreach (var pair in FrequencyMagnitudes) - { - double frequency = pair.Key; - double magnitude = pair.Value; - if (IsHumFrequency(frequency)) - { - if (magnitude > MinNoiseMagnitude) - { - totalHumMagnitude += magnitude; - } - } - } - return totalHumMagnitude; - } - - private OrcanodeOnlineStatus GetStatus(OrcanodeOnlineStatus oldStatus) - { - double max = MaxMagnitude; - if (max < MinNoiseMagnitude) - { - // File contains mostly silence across all frequencies. - return OrcanodeOnlineStatus.Silent; - } - - if ((max <= MaxSilenceMagnitude) && (oldStatus == OrcanodeOnlineStatus.Silent)) - { - // In between the min and max silence range, so keep previous status. - return oldStatus; - } - - // Find the total magnitude outside the audio hum range. - if (GetMaxNonHumMagnitude() < MinNoiseMagnitude) - { - // Just silence outside the hum range, no signal. - return OrcanodeOnlineStatus.Unintelligible; - } - - double totalNonHumMagnitude = GetTotalNonHumMagnitude(); - double totalHumMagnitude = GetTotalHumMagnitude(); - if (totalNonHumMagnitude / totalHumMagnitude < MinSignalRatio) - { - // Essentially just silence outside the hum range, no signal. - return OrcanodeOnlineStatus.Unintelligible; - } - - // Signal outside the hum range. - return OrcanodeOnlineStatus.Online; - } - } - public class FfmpegCoreAnalyzer { /// diff --git a/OrcanodeMonitor/Core/FrequencyInfo.cs b/OrcanodeMonitor/Core/FrequencyInfo.cs new file mode 100644 index 0000000..048117b --- /dev/null +++ b/OrcanodeMonitor/Core/FrequencyInfo.cs @@ -0,0 +1,308 @@ +// Copyright (c) Orcanode Monitor contributors +// SPDX-License-Identifier: MIT +using MathNet.Numerics.IntegralTransforms; +using OrcanodeMonitor.Models; +using System.Diagnostics; +using System.Numerics; + +namespace OrcanodeMonitor.Core +{ + public class FrequencyInfo + { + /// + /// Given an audio clip, compute frequency info for it. + /// + /// Raw audio data as float array + /// Audio sample rate in Hz + /// Number of audio channels + /// Previous online status for hysteresis + public FrequencyInfo(float[] data, int sampleRate, int channels, OrcanodeOnlineStatus oldStatus) + { + ChannelCount = channels; + FrequencyMagnitudesForChannel = new Dictionary[channels]; + StatusForChannel = new OrcanodeOnlineStatus[channels]; + FrequencyMagnitudes = new Dictionary(); + ComputeFrequencyMagnitudes(data, sampleRate, channels); + Status = GetStatus(oldStatus); + for (int i = 0; i < channels; i++) + { + StatusForChannel[i] = GetStatus(oldStatus, i); + } + } + + private void ComputeFrequencyMagnitudes(float[] data, int sampleRate, int channelCount) + { + if (data == null || data.Length == 0) + throw new ArgumentException("Audio data cannot be null or empty", nameof(data)); +#if false + // TODO: there seems to be some issue here to track down. + if (data.Length % channelCount != 0) + throw new ArgumentException("Data length must be divisible by channel count", nameof(data)); +#endif + if (sampleRate <= 0) + throw new ArgumentException("Sample rate must be positive", nameof(sampleRate)); + + int n = data.Length / channelCount; + + // Create an array of complex data for each channel. + Complex[][] complexData = new Complex[channelCount][]; + for (int ch = 0; ch < channelCount; ch++) + { + complexData[ch] = new Complex[n]; + } + + // Populate the complex arrays with channel data. + for (int i = 0; i < n; i++) + { + for (int ch = 0; ch < channelCount; ch++) + { + complexData[ch][i] = new Complex(data[i * channelCount + ch], 0); + } + } + + // Perform Fourier transform for each channel. + for (int ch = 0; ch < channelCount; ch++) + { + Fourier.Forward(complexData[ch], FourierOptions.Matlab); + FrequencyMagnitudesForChannel[ch] = new Dictionary(n / 2); + for (int i = 0; i < n / 2; i++) + { + double magnitude = complexData[ch][i].Magnitude; + double frequency = (((double)i) * sampleRate) / n; + FrequencyMagnitudesForChannel[ch][frequency] = magnitude; + } + } + + // Combine results from all channels. + foreach (var channelResult in FrequencyMagnitudesForChannel) + { + foreach (var kvp in channelResult) + { + if (!FrequencyMagnitudes.ContainsKey(kvp.Key)) + { + FrequencyMagnitudes[kvp.Key] = 0; + } + if (FrequencyMagnitudes[kvp.Key] < kvp.Value) + { + FrequencyMagnitudes[kvp.Key] = kvp.Value; + } + } + } + } + + // We consider anything above this average magnitude as not silence. + const double _defaultMaxSilenceMagnitude = 20.0; + public static double MaxSilenceMagnitude + { + get + { + string? maxSilenceMagnitudeString = Environment.GetEnvironmentVariable("ORCASOUND_MAX_SILENCE_MAGNITUDE"); + double maxSilenceMagnitude = double.TryParse(maxSilenceMagnitudeString, out var magnitude) ? magnitude : _defaultMaxSilenceMagnitude; + return maxSilenceMagnitude; + } + } + + // We consider anything below this average magnitude as silence. + const double _defaultMinNoiseMagnitude = 15.0; + public static double MinNoiseMagnitude + { + get + { + string? minNoiseMagnitudeString = Environment.GetEnvironmentVariable("ORCASOUND_MIN_NOISE_MAGNITUDE"); + double minNoiseMagnitude = double.TryParse(minNoiseMagnitudeString, out var magnitude) ? magnitude : _defaultMinNoiseMagnitude; + return minNoiseMagnitude; + } + } + + // Minimum ratio of magnitude outside the hum range to magnitude + // within the hum range. So far the max in a known-unintelligible + // sample is 53% and the min in a known-good sample is 114%. + const double _defaultMinSignalPercent = 100; + private static double MinSignalRatio + { + get + { + string? minSignalPercentString = Environment.GetEnvironmentVariable("ORCASOUND_MIN_INTELLIGIBLE_SIGNAL_PERCENT"); + double minSignalPercent = double.TryParse(minSignalPercentString, out var percent) ? percent : _defaultMinSignalPercent; + return minSignalPercent / 100.0; + } + } + + // Data members. + + private Dictionary FrequencyMagnitudes { get; } + private Dictionary[] FrequencyMagnitudesForChannel { get; } + public OrcanodeOnlineStatus Status { get; } + public OrcanodeOnlineStatus[] StatusForChannel { get; } + public int ChannelCount { get; private set; } = 0; + + /// + /// URL at which the original audio sample can be found. + /// + public string AudioSampleUrl { get; set; } = string.Empty; + + public Dictionary GetFrequencyMagnitudes(int? channel = null) + { + return (channel.HasValue) ? FrequencyMagnitudesForChannel[channel.Value] : FrequencyMagnitudes; + } + + public double GetMaxMagnitude(int? channel = null) => GetFrequencyMagnitudes(channel).Values.Max(); + + /// + /// Compute the ratio between non-hum and hum frequencies in the audio signal. + /// This ratio helps determine if the signal is intelligible or just noise. + /// + /// Channel number, or null for an aggregate + /// + /// The ratio of non-hum to hum frequencies. A higher ratio indicates a clearer signal. + /// Returns 0 when no hum is detected to avoid division by zero. + /// + /// + /// The ratio is calculated by dividing the total magnitude of non-hum frequencies + /// by the total magnitude of hum frequencies (50Hz and 60Hz bands). + /// A minimum value of 1 is used for hum magnitude to prevent division by zero. + /// + public double GetSignalRatio(int? channel = null) + { + double hum = Math.Max(GetTotalHumMagnitude(channel), 1); + return GetTotalNonHumMagnitude(channel) / hum; + } + + // Microphone audio hum typically falls within the 50 Hz or 60 Hz + // range. This hum is often caused by electrical interference from + // power lines and other electronic devices. + const double HumFrequency1 = 50.0; // Hz + const double HumFrequency2 = 60.0; // Hz + private static bool IsHumFrequency(double frequency, double humFrequency) + { + Debug.Assert(frequency >= 0.0); + Debug.Assert(humFrequency >= 0.0); + const double tolerance = 1.0; + double remainder = frequency % humFrequency; + return (remainder < tolerance || remainder > (humFrequency - tolerance)); + } + + public static bool IsHumFrequency(double frequency) => IsHumFrequency(frequency, HumFrequency1) || IsHumFrequency(frequency, HumFrequency2); + + /// + /// Find the maximum magnitude outside the audio hum range among a set of frequency magnitudes. + /// + /// Magnitude + private double GetMaxNonHumMagnitude(Dictionary frequencyMagnitudes) + { + double maxNonHumMagnitude = 0; + foreach (var pair in frequencyMagnitudes) + { + double frequency = pair.Key; + double magnitude = pair.Value; + if (!IsHumFrequency(frequency)) + { + if (maxNonHumMagnitude < magnitude) + { + maxNonHumMagnitude = magnitude; + } + } + } + return maxNonHumMagnitude; + } + + /// + /// Find the maximum magnitude outside the audio hum range. + /// + /// Channel, or null for all + /// Magnitude + public double GetMaxNonHumMagnitude(int? channel = null) => GetMaxNonHumMagnitude(GetFrequencyMagnitudes(channel)); + + /// + /// Find the total magnitude outside the audio hum range among a given set of frequency magnitudes. + /// + /// Magnitude + private double GetTotalNonHumMagnitude(Dictionary frequencyMagnitudes) + { + double totalNonHumMagnitude = 0; + foreach (var pair in frequencyMagnitudes) + { + double frequency = pair.Key; + double magnitude = pair.Value; + if (!IsHumFrequency(frequency)) + { + if (magnitude > MinNoiseMagnitude) + { + totalNonHumMagnitude += magnitude; + } + } + } + return totalNonHumMagnitude; + } + + /// + /// Find the total magnitude outside the audio hum range. + /// + /// Channel, or null for all + /// Magnitude + public double GetTotalNonHumMagnitude(int? channel = null) => GetTotalNonHumMagnitude(GetFrequencyMagnitudes(channel)); + + /// + /// Find the total magnitude of the audio hum range among a given set of frequency magnitudes. + /// + /// Magnitude + public double GetTotalHumMagnitude(Dictionary frequencyMagnitudes) + { + double totalHumMagnitude = 0; + foreach (var pair in frequencyMagnitudes) + { + double frequency = pair.Key; + double magnitude = pair.Value; + if (IsHumFrequency(frequency)) + { + if (magnitude > MinNoiseMagnitude) + { + totalHumMagnitude += magnitude; + } + } + } + return totalHumMagnitude; + } + + /// + /// Find the total magnitude of the audio hum range. + /// + /// Channel, or null for all + /// Magnitude + public double GetTotalHumMagnitude(int? channel = null) => GetTotalHumMagnitude(GetFrequencyMagnitudes(channel)); + + private OrcanodeOnlineStatus GetStatus(OrcanodeOnlineStatus oldStatus, int? channel = null) + { + double max = GetMaxMagnitude(channel); + if (max < MinNoiseMagnitude) + { + // File contains mostly silence across all frequencies. + return OrcanodeOnlineStatus.Silent; + } + + if ((max <= MaxSilenceMagnitude) && (oldStatus == OrcanodeOnlineStatus.Silent)) + { + // In between the min and max silence range, so keep previous status. + return oldStatus; + } + + // Find the total magnitude outside the audio hum range. + if (GetMaxNonHumMagnitude(channel) < MinNoiseMagnitude) + { + // Just silence outside the hum range, no signal. + return OrcanodeOnlineStatus.Unintelligible; + } + + double totalNonHumMagnitude = GetTotalNonHumMagnitude(channel); + double totalHumMagnitude = GetTotalHumMagnitude(channel); + if (totalNonHumMagnitude / totalHumMagnitude < MinSignalRatio) + { + // Essentially just silence outside the hum range, no signal. + return OrcanodeOnlineStatus.Unintelligible; + } + + // Signal outside the hum range. + return OrcanodeOnlineStatus.Online; + } + } +} diff --git a/OrcanodeMonitor/Pages/NodeEvents.cshtml b/OrcanodeMonitor/Pages/NodeEvents.cshtml index 15fa56d..207a396 100644 --- a/OrcanodeMonitor/Pages/NodeEvents.cshtml +++ b/OrcanodeMonitor/Pages/NodeEvents.cshtml @@ -26,14 +26,15 @@ var mezmoData = @Html.Raw(Model.JsonMezmoData); var hydrophoneStreamData = @Html.Raw(Model.JsonHydrophoneStreamData); var statusLabels = { - 0: 'Unconfigured', - 0.9: 'Down', - 0.95: 'Down', - 1: 'Down', - 2: 'Unintelligible', - 2.9: 'Up', - 2.95: 'Up', - 3: 'Up' + '-1': ' ', + '0': 'Unconfigured', + '0.9': 'Down', + '0.95': 'Down', + '1': 'Down', + '2': 'Unintelligible', + '2.9': 'Up', + '2.95': 'Up', + '3': 'Up', }; var myLineChart = new Chart(ctx, { diff --git a/OrcanodeMonitor/Pages/SpectralDensity.cshtml b/OrcanodeMonitor/Pages/SpectralDensity.cshtml index 775e3bb..aec69ba 100644 --- a/OrcanodeMonitor/Pages/SpectralDensity.cshtml +++ b/OrcanodeMonitor/Pages/SpectralDensity.cshtml @@ -21,13 +21,16 @@ data: { labels: @Html.Raw(Json.Serialize(Model.Labels)), datasets: [ + @for (int i = 0; i < Model.ChannelCount; i++) { - label: 'Audio Sample', - data: @Html.Raw(Json.Serialize(Model.MaxBucketMagnitude)), - backgroundColor: 'rgba(75, 192, 192, 0.2)', - borderColor: 'rgba(75, 192, 192, 1)', - borderWidth: 1 - }, + @: { + @: label: 'Channel @(i+1)', + @: data: @Html.Raw(Model.JsonChannelDatasets[i]), + @: backgroundColor: @Html.Raw(Model.GetChannelColor(@i, 0.2)), + @: borderColor: @Html.Raw(Model.GetChannelColor(@i, 1)), + @: borderWidth: 1 + @: }, + } { label: 'Max Noise Magnitude', data: [ @@ -74,12 +77,33 @@

Statistics

- Max magnitude: @Model.MaxMagnitude
- Max magnitude outside hum range: @Model.MaxNonHumMagnitude
- Total magnitude outside hum range: @Model.TotalNonHumMagnitude
- Total magnitude of hum range: @Model.TotalHumMagnitude
- Signal ratio: @Model.SignalRatio%
- Status: @Model.Status
+ # Channels: @Model.ChannelCount
+ +

Summary

+

+ Max magnitude: @Model.MaxMagnitude
+ Max magnitude outside hum range: @Model.MaxNonHumMagnitude
+ Total magnitude outside hum range: @Model.TotalNonHumMagnitude
+ Total magnitude of hum range: @Model.TotalHumMagnitude
+ Signal ratio: @Model.SignalRatio %
+ Status: @Model.Status
+

+ @if (Model.ChannelCount > 1) + { + @for (int i = 0; i < Model.ChannelCount; i++) + { +

Channel @(i + 1):

+

+ Max magnitude: @Model.GetMaxMagnitude(i)
+ Max magnitude outside hum range: @Model.GetMaxNonHumMagnitude(i)
+ Total magnitude outside hum range: @Model.GetTotalNonHumMagnitude(i)
+ Total magnitude of hum range: @Model.GetTotalHumMagnitude(i)
+ Signal ratio: @Model.GetSignalRatio(i) %
+ Status: @Model.GetStatus(i)
+

+ } + } + diff --git a/OrcanodeMonitor/Pages/SpectralDensity.cshtml.cs b/OrcanodeMonitor/Pages/SpectralDensity.cshtml.cs index 2dad1e7..c9d8ac3 100644 --- a/OrcanodeMonitor/Pages/SpectralDensity.cshtml.cs +++ b/OrcanodeMonitor/Pages/SpectralDensity.cshtml.cs @@ -1,9 +1,11 @@ // Copyright (c) Orcanode Monitor contributors // SPDX-License-Identifier: MIT using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.CodeAnalysis; using OrcanodeMonitor.Core; using OrcanodeMonitor.Data; using OrcanodeMonitor.Models; +using System.Text.Json; using static OrcanodeMonitor.Core.Fetcher; namespace OrcanodeMonitor.Pages @@ -21,15 +23,15 @@ public class SpectralDensityModel : PageModel private Orcanode? _node = null; public string NodeName => _node?.DisplayName ?? "Unknown"; private List _labels; - private List _maxBucketMagnitude; public List Labels => _labels; - public List MaxBucketMagnitude => _maxBucketMagnitude; public string AudioUrl => _event?.Url ?? "Unknown"; public int MaxMagnitude { get; private set; } + public int ChannelCount { get; private set; } public int TotalNonHumMagnitude => (int)Math.Round(_totalNonHumMagnitude); public int TotalHumMagnitude => (int)Math.Round(_totalHumMagnitude); private double _totalHumMagnitude; private double _totalNonHumMagnitude; + private FrequencyInfo? _frequencyInfo = null; public int MaxNonHumMagnitude { get; private set; } public int SignalRatio { get; private set; } public string Status { get; private set; } @@ -44,54 +46,176 @@ public SpectralDensityModel(OrcanodeMonitorContext context, ILogger(); - _maxBucketMagnitude = new List(); LastModified = string.Empty; } - private void UpdateFrequencyInfo(FrequencyInfo frequencyInfo) + /// + /// Maximum frequency to analyze in Hz. + /// Typical human hearing range is up to 20kHz. + /// Orca calls are up to 40kHz. + /// + private const int MAX_FREQUENCY = 24000; + + /// + /// Number of points to plot on the graph. 1000 points provides a good balance + /// between resolution and performance. + /// + private const int POINT_COUNT = 1000; + + private void FillInGraphPoints(List labels, List maxBucketMagnitudeList, int? channel = null) { - const int MaxFrequency = 24000; - const int PointCount = 1000; + if (_frequencyInfo == null) + { + return; + } // Compute the logarithmic base needed to get PointCount points. - double b = Math.Pow(MaxFrequency, 1.0 / PointCount); + double b = Math.Pow(MAX_FREQUENCY, 1.0 / POINT_COUNT); double logb = Math.Log(b); - double maxMagnitude = frequencyInfo.MaxMagnitude; - var maxBucketMagnitude = new double[PointCount]; - var maxBucketFrequency = new int[PointCount]; - - foreach (var pair in frequencyInfo.FrequencyMagnitudes) + var maxBucketMagnitude = new double[POINT_COUNT]; + var maxBucketFrequency = new int[POINT_COUNT]; + foreach (var pair in _frequencyInfo.GetFrequencyMagnitudes(channel)) { double frequency = pair.Key; double magnitude = pair.Value; - int bucket = (frequency < 1) ? 0 : Math.Min(PointCount - 1, (int)(Math.Log(frequency) / logb)); + int bucket = (frequency < 1) ? 0 : Math.Min(POINT_COUNT - 1, (int)(Math.Log(frequency) / logb)); if (maxBucketMagnitude[bucket] < magnitude) { maxBucketMagnitude[bucket] = magnitude; maxBucketFrequency[bucket] = (int)Math.Round(frequency); } } - - // Fill in graph points. - for (int i = 0; i < PointCount; i++) + for (int i = 0; i < POINT_COUNT; i++) { if (maxBucketMagnitude[i] > 0) { - _labels.Add(maxBucketFrequency[i].ToString()); - _maxBucketMagnitude.Add(maxBucketMagnitude[i]); + labels.Add(maxBucketFrequency[i].ToString()); + maxBucketMagnitudeList.Add(maxBucketMagnitude[i]); } } + } - double maxNonHumMagnitude = frequencyInfo.GetMaxNonHumMagnitude(); - MaxMagnitude = (int)Math.Round(maxMagnitude); - MaxNonHumMagnitude = (int)Math.Round(maxNonHumMagnitude); - Status = Orcanode.GetStatusString(frequencyInfo.Status); - _totalHumMagnitude = frequencyInfo.GetTotalHumMagnitude(); - _totalNonHumMagnitude = frequencyInfo.GetTotalNonHumMagnitude(); - SignalRatio = (int)Math.Round(100 * _totalNonHumMagnitude / _totalHumMagnitude); + private double GetBucketMagnitude(string label, List labels, List magnitudes) + { + double sum = 0; + for (int i = 0; i < labels.Count; i++) + { + if (labels[i] == label) + { + sum += magnitudes[i]; + } + } + return sum; } + private void UpdateFrequencyInfo() + { + if (_frequencyInfo == null) + { + return; + } + + // Compute graph points. + var summaryLabels = new List(); + var summaryMaxBucketMagnitude = new List(); + FillInGraphPoints(summaryLabels, summaryMaxBucketMagnitude); + var channelLabels = new List[_frequencyInfo.ChannelCount]; + var channelMaxBucketMagnitude = new List[_frequencyInfo.ChannelCount]; + for (int i = 0; i < _frequencyInfo.ChannelCount; i++) + { + channelLabels[i] = new List(); + channelMaxBucketMagnitude[i] = new List(); + FillInGraphPoints(channelLabels[i], channelMaxBucketMagnitude[i], i); + } + + // Collect all labels. + var mainLabels = new HashSet(summaryLabels); + for (int i = 0; i < _frequencyInfo.ChannelCount; i++) + { + mainLabels.UnionWith(channelLabels[i]); + } + _labels = mainLabels.ToList(); + + // Align data. + var summaryDataset = _labels.Select(label => new + { + x = label, + y = summaryLabels.Contains(label) ? GetBucketMagnitude(label, summaryLabels, summaryMaxBucketMagnitude) : (double?)null + }).ToList(); + var channelDatasets = new List>(); + for (int i = 0; i < _frequencyInfo.ChannelCount; i++) + { + var channelDataset = _labels.Select(label => new + { + x = label, + y = channelLabels[i].Contains(label) ? GetBucketMagnitude(label, channelLabels[i], channelMaxBucketMagnitude[i]) : (double?)null + }).ToList(); + channelDatasets.Add(channelDataset); + } + + // Serialise to JSON. + JsonSummaryDataset = JsonSerializer.Serialize(summaryDataset); + JsonChannelDatasets = channelDatasets.Select(dataset => JsonSerializer.Serialize(dataset)).ToList(); + + MaxMagnitude = (int)Math.Round(_frequencyInfo.GetMaxMagnitude()); + MaxNonHumMagnitude = (int)Math.Round(_frequencyInfo.GetMaxNonHumMagnitude()); + ChannelCount = _frequencyInfo.ChannelCount; + Status = Orcanode.GetStatusString(_frequencyInfo.Status); + _totalHumMagnitude = _frequencyInfo.GetTotalHumMagnitude(); + _totalNonHumMagnitude = _frequencyInfo.GetTotalNonHumMagnitude(); + SignalRatio = (int)Math.Round(100 * _frequencyInfo.GetSignalRatio()); + } + + /// + /// Gets or sets the JSON-serialized dataset containing summary frequency magnitudes. + /// Can be used by Chart.js for visualization, but isn't currently. + /// + public string JsonSummaryDataset { get; set; } + + /// + /// Gets or sets the JSON-serialized datasets containing per-channel frequency magnitudes. + /// Used by Chart.js for visualization when multiple channels are present. + /// + public List JsonChannelDatasets { get; set; } + + public string GetChannelColor(int channelIndex, double alpha) + { + var colors = new[] { + (54, 235, 127), // Green + (153, 102, 255), // Purple + (255, 159, 64), // Orange + (255, 206, 86), // Yellow + (75, 192, 192), // Teal + (255, 99, 132), // Pink + (54, 162, 235), // Blue + }; + var (r, g, b) = colors[channelIndex % colors.Length]; + return $"'rgba({r}, {g}, {b}, {alpha})'"; + } + + /// + /// Gets the maximum magnitude for a specific channel. + /// + /// The channel index to get the magnitude for. + /// The maximum magnitude for the specified channel, or 0 if no data is available. + public int GetMaxMagnitude(int channel) => (int)Math.Round(_frequencyInfo?.GetMaxMagnitude(channel) ?? 0); + + /// + /// Gets the maximum non-hum magnitude for a specific channel. + /// + /// The channel index to get the magnitude for. + /// The maximum non-hum magnitude for the specified channel, or 0 if no data is available. + public int GetMaxNonHumMagnitude(int channel) => (int)Math.Round(_frequencyInfo?.GetMaxNonHumMagnitude(channel) ?? 0); + + public int GetTotalHumMagnitude(int channel) => (int)Math.Round(_frequencyInfo?.GetTotalHumMagnitude(channel) ?? 0); + + public int GetTotalNonHumMagnitude(int channel) => (int)Math.Round(_frequencyInfo?.GetTotalNonHumMagnitude(channel) ?? 0); + + public int GetSignalRatio(int channel) => (int)Math.Round(100 * _frequencyInfo?.GetSignalRatio(channel) ?? 0); + + public string GetStatus(int channel) => Orcanode.GetStatusString(_frequencyInfo?.StatusForChannel[channel] ?? OrcanodeOnlineStatus.Absent); + private async Task UpdateNodeFrequencyDataAsync() { if (_node == null) @@ -101,10 +225,14 @@ private async Task UpdateNodeFrequencyDataAsync() TimestampResult? result = await GetLatestS3TimestampAsync(_node, false, _logger); if (result != null) { - FrequencyInfo? frequencyInfo = await Fetcher.GetLatestAudioSampleAsync(_node, result.UnixTimestampString, false, _logger); - if (frequencyInfo != null) + try + { + _frequencyInfo = await Fetcher.GetLatestAudioSampleAsync(_node, result.UnixTimestampString, false, _logger); + UpdateFrequencyInfo(); + } + catch (Exception ex) { - UpdateFrequencyInfo(frequencyInfo); + _logger.LogError(ex, "Failed to fetch audio sample for node {NodeId}", _node.ID); } } } @@ -125,10 +253,14 @@ private async Task UpdateEventFrequencyDataAsync() DateTime? lastModified = await Fetcher.GetLastModifiedAsync(uri); LastModified = lastModified?.ToLocalTime().ToString() ?? "Unknown"; - FrequencyInfo? frequencyInfo = await Fetcher.GetExactAudioSampleAsync(_node, uri, _logger); - if (frequencyInfo != null) + try + { + _frequencyInfo = await Fetcher.GetExactAudioSampleAsync(_node, uri, _logger); + UpdateFrequencyInfo(); + } + catch (Exception ex) { - UpdateFrequencyInfo(frequencyInfo); + _logger.LogError(ex, "Failed to fetch audio sample for event {EventId}", _id); } }