Remove music button and MP3 decode, keep live capture only
- Remove Music button, OnMusic/RunMusicAsync/StopMusic and all music fields - MusicAnalyzer: remove Analyze, DecodeToMono, MusicPattern record; keep ExtractFeatures/MapPitchToPeriod/BuildWaveFrame/TickFeature (used by LiveCapture) - Remove unused NAudio.Wave and System.Diagnostics imports from MainForm - Fix LiveCapture overlap: sliding window with 50% overlap (was dropping every other frame) - Buttons resized to 80px (Test/Live/Pattern/Stop All)
This commit is contained in:
+15
-1
@@ -91,6 +91,7 @@ public class LiveCapture : IDisposable
|
|||||||
var window = new FftSharp.Windows.Hanning();
|
var window = new FftSharp.Windows.Hanning();
|
||||||
var buffer = new double[MusicAnalyzer.WindowSize];
|
var buffer = new double[MusicAnalyzer.WindowSize];
|
||||||
var tf = new MusicAnalyzer.TickFeature();
|
var tf = new MusicAnalyzer.TickFeature();
|
||||||
|
double[]? overlap = null; // last HopSize samples from previous window
|
||||||
|
|
||||||
while (_running)
|
while (_running)
|
||||||
{
|
{
|
||||||
@@ -98,10 +99,23 @@ public class LiveCapture : IDisposable
|
|||||||
|
|
||||||
lock (_bufferLock)
|
lock (_bufferLock)
|
||||||
{
|
{
|
||||||
if (_sampleBuffer.Count >= MusicAnalyzer.WindowSize)
|
int needed = overlap != null ? MusicAnalyzer.HopSize : MusicAnalyzer.WindowSize;
|
||||||
|
if (_sampleBuffer.Count >= needed)
|
||||||
|
{
|
||||||
|
if (overlap != null)
|
||||||
|
{
|
||||||
|
Array.Copy(overlap, 0, buffer, 0, MusicAnalyzer.HopSize);
|
||||||
|
for (int i = 0; i < MusicAnalyzer.HopSize; i++)
|
||||||
|
buffer[MusicAnalyzer.HopSize + i] = _sampleBuffer.Dequeue();
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
for (int i = 0; i < MusicAnalyzer.WindowSize; i++)
|
for (int i = 0; i < MusicAnalyzer.WindowSize; i++)
|
||||||
buffer[i] = _sampleBuffer.Dequeue();
|
buffer[i] = _sampleBuffer.Dequeue();
|
||||||
|
}
|
||||||
|
// Save last HopSize samples for next window's overlap
|
||||||
|
overlap = new double[MusicAnalyzer.HopSize];
|
||||||
|
Array.Copy(buffer, MusicAnalyzer.HopSize, overlap, 0, MusicAnalyzer.HopSize);
|
||||||
windowData = buffer;
|
windowData = buffer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-131
@@ -1,8 +1,6 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Drawing.Drawing2D;
|
using System.Drawing.Drawing2D;
|
||||||
using System.Diagnostics;
|
|
||||||
using NAudio.CoreAudioApi;
|
using NAudio.CoreAudioApi;
|
||||||
using NAudio.Wave;
|
|
||||||
|
|
||||||
namespace Substation;
|
namespace Substation;
|
||||||
|
|
||||||
@@ -48,17 +46,13 @@ public class MainForm : Form
|
|||||||
readonly Icon _iconHot;
|
readonly Icon _iconHot;
|
||||||
string _trayState = "";
|
string _trayState = "";
|
||||||
|
|
||||||
readonly Button _btnMusic;
|
|
||||||
readonly Button _btnLive;
|
readonly Button _btnLive;
|
||||||
readonly Button _btnPattern;
|
readonly Button _btnPattern;
|
||||||
readonly Label _lblTrack;
|
readonly Label _lblTrack;
|
||||||
readonly Label _lblAudioDev;
|
readonly Label _lblAudioDev;
|
||||||
readonly ComboBox _cboAudioDev;
|
readonly ComboBox _cboAudioDev;
|
||||||
bool _isMusicRunning;
|
|
||||||
bool _isLiveRunning;
|
bool _isLiveRunning;
|
||||||
bool _isPatternRunning;
|
bool _isPatternRunning;
|
||||||
WaveOutEvent? _audioOut;
|
|
||||||
Stopwatch? _musicStopwatch;
|
|
||||||
LiveCapture? _liveCapture;
|
LiveCapture? _liveCapture;
|
||||||
|
|
||||||
public MainForm(CoyoteDevice device, State state, Server server)
|
public MainForm(CoyoteDevice device, State state, Server server)
|
||||||
@@ -125,39 +119,31 @@ public class MainForm : Form
|
|||||||
{
|
{
|
||||||
Text = "Test",
|
Text = "Test",
|
||||||
Location = new Point(16, 96),
|
Location = new Point(16, 96),
|
||||||
Size = new Size(68, 32)
|
Size = new Size(80, 32)
|
||||||
};
|
};
|
||||||
_btnTest.Click += OnTest;
|
_btnTest.Click += OnTest;
|
||||||
|
|
||||||
_btnMusic = new Button
|
|
||||||
{
|
|
||||||
Text = "Music",
|
|
||||||
Location = new Point(92, 96),
|
|
||||||
Size = new Size(68, 32)
|
|
||||||
};
|
|
||||||
_btnMusic.Click += OnMusic;
|
|
||||||
|
|
||||||
_btnLive = new Button
|
_btnLive = new Button
|
||||||
{
|
{
|
||||||
Text = "Live",
|
Text = "Live",
|
||||||
Location = new Point(168, 96),
|
Location = new Point(104, 96),
|
||||||
Size = new Size(68, 32)
|
Size = new Size(80, 32)
|
||||||
};
|
};
|
||||||
_btnLive.Click += OnLive;
|
_btnLive.Click += OnLive;
|
||||||
|
|
||||||
_btnPattern = new Button
|
_btnPattern = new Button
|
||||||
{
|
{
|
||||||
Text = "Pattern",
|
Text = "Pattern",
|
||||||
Location = new Point(244, 96),
|
Location = new Point(192, 96),
|
||||||
Size = new Size(68, 32)
|
Size = new Size(80, 32)
|
||||||
};
|
};
|
||||||
_btnPattern.Click += OnPattern;
|
_btnPattern.Click += OnPattern;
|
||||||
|
|
||||||
_btnStop = new Button
|
_btnStop = new Button
|
||||||
{
|
{
|
||||||
Text = "Stop All",
|
Text = "Stop All",
|
||||||
Location = new Point(320, 96),
|
Location = new Point(280, 96),
|
||||||
Size = new Size(76, 32)
|
Size = new Size(80, 32)
|
||||||
};
|
};
|
||||||
_btnStop.Click += OnStop;
|
_btnStop.Click += OnStop;
|
||||||
|
|
||||||
@@ -307,7 +293,7 @@ public class MainForm : Form
|
|||||||
_chkScaleB.CheckedChanged += OnLimitChanged;
|
_chkScaleB.CheckedChanged += OnLimitChanged;
|
||||||
OnLimitChanged(null, EventArgs.Empty);
|
OnLimitChanged(null, EventArgs.Empty);
|
||||||
|
|
||||||
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnTest, _btnMusic, _btnLive, _btnPattern, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB });
|
Controls.AddRange(new Control[] { _lblBle, _lblWs, _lblDeviceName, _txtDeviceName, _btnConnect, _chkSwap, _btnTest, _btnLive, _btnPattern, _btnStop, _lblAudioDev, _cboAudioDev, _lblTrack, _lblSendA, _heatA, _lblSendB, _heatB, _lblRecvA, _gaugeRecvA, _lblRecvB, _gaugeRecvB, _lblLimitA, _limitBarA, _lblLimitValA, _chkScaleA, _lblLimitB, _limitBarB, _lblLimitValB, _chkScaleB });
|
||||||
|
|
||||||
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
|
// Tray icon — three cached variants: neutral=gray, active=gold, hot=red-orange
|
||||||
_iconNeutral = CreateVoltageIcon(Color.Gray);
|
_iconNeutral = CreateVoltageIcon(Color.Gray);
|
||||||
@@ -475,9 +461,8 @@ public class MainForm : Form
|
|||||||
|
|
||||||
void RefreshButtonStates()
|
void RefreshButtonStates()
|
||||||
{
|
{
|
||||||
bool busy = IsTestRunning || _isMusicRunning || _isLiveRunning || _isPatternRunning;
|
bool busy = IsTestRunning || _isLiveRunning || _isPatternRunning;
|
||||||
_btnTest.Enabled = !_server.HasClient && !busy;
|
_btnTest.Enabled = !_server.HasClient && !busy;
|
||||||
_btnMusic.Enabled = !_server.HasClient && !busy;
|
|
||||||
_btnLive.Enabled = !_server.HasClient && !busy;
|
_btnLive.Enabled = !_server.HasClient && !busy;
|
||||||
_btnPattern.Enabled = !_server.HasClient && !busy;
|
_btnPattern.Enabled = !_server.HasClient && !busy;
|
||||||
_btnStop.Enabled = true;
|
_btnStop.Enabled = true;
|
||||||
@@ -487,12 +472,7 @@ public class MainForm : Form
|
|||||||
{
|
{
|
||||||
if (IsDisposed) return;
|
if (IsDisposed) return;
|
||||||
|
|
||||||
if (_isMusicRunning && _musicStopwatch != null)
|
if (_isLiveRunning)
|
||||||
{
|
|
||||||
var elapsed = _musicStopwatch.Elapsed;
|
|
||||||
_lblTrack.Text = $"Playing {elapsed:mm\\:ss} / {_musicTotalTime:mm\\:ss} {Path.GetFileName(_musicFilePath)}";
|
|
||||||
}
|
|
||||||
else if (_isLiveRunning)
|
|
||||||
{
|
{
|
||||||
_lblTrack.Text = "Live capture";
|
_lblTrack.Text = "Live capture";
|
||||||
}
|
}
|
||||||
@@ -564,7 +544,6 @@ public class MainForm : Form
|
|||||||
|
|
||||||
void OnStop(object? sender, EventArgs e)
|
void OnStop(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
StopMusic();
|
|
||||||
StopLive();
|
StopLive();
|
||||||
StopPattern();
|
StopPattern();
|
||||||
_state.SetStrength('A', 0);
|
_state.SetStrength('A', 0);
|
||||||
@@ -590,106 +569,6 @@ public class MainForm : Form
|
|||||||
_state.SwapChannels = _chkSwap.Checked;
|
_state.SwapChannels = _chkSwap.Checked;
|
||||||
}
|
}
|
||||||
|
|
||||||
string _musicFilePath = "";
|
|
||||||
TimeSpan _musicTotalTime;
|
|
||||||
|
|
||||||
void OnMusic(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (_isMusicRunning || _server.HasClient) return;
|
|
||||||
|
|
||||||
using var dlg = new OpenFileDialog
|
|
||||||
{
|
|
||||||
Filter = "MP3 files (*.mp3)|*.mp3|All files (*.*)|*.*",
|
|
||||||
Title = "Select music to drive e-stim"
|
|
||||||
};
|
|
||||||
if (dlg.ShowDialog() != DialogResult.OK) return;
|
|
||||||
|
|
||||||
_musicFilePath = dlg.FileName;
|
|
||||||
_musicTotalTime = TimeSpan.Zero;
|
|
||||||
_isMusicRunning = true;
|
|
||||||
_btnMusic.Enabled = false;
|
|
||||||
_btnTest.Enabled = false;
|
|
||||||
_lblTrack.Text = $"Analyzing... {Path.GetFileName(_musicFilePath)}";
|
|
||||||
|
|
||||||
var filePath = _musicFilePath;
|
|
||||||
Task.Run(() => RunMusicAsync(filePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
async Task RunMusicAsync(string filePath)
|
|
||||||
{
|
|
||||||
MusicPattern? pattern = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var progress = new Progress<int>(pct =>
|
|
||||||
{
|
|
||||||
if (!IsDisposed)
|
|
||||||
_lblTrack.Text = $"Analyzing... {pct}% {Path.GetFileName(filePath)}";
|
|
||||||
});
|
|
||||||
pattern = await Task.Run(() => MusicAnalyzer.Analyze(filePath, (IProgress<int>)progress));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
BeginInvoke(() =>
|
|
||||||
{
|
|
||||||
_lblTrack.Text = $"Analysis failed: {ex.Message}";
|
|
||||||
_isMusicRunning = false;
|
|
||||||
RefreshButtonStates();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_musicTotalTime = pattern.Duration;
|
|
||||||
|
|
||||||
// Set a moderate strength ceiling for music
|
|
||||||
_state.SetStrength('A', 60);
|
|
||||||
_state.SetStrength('B', 60);
|
|
||||||
|
|
||||||
// Clear any existing patterns and enqueue all music frames
|
|
||||||
_state.Stop('A');
|
|
||||||
_state.Stop('B');
|
|
||||||
_state.EnqueueStream('A', pattern.ChannelA);
|
|
||||||
_state.EnqueueStream('B', pattern.ChannelB);
|
|
||||||
|
|
||||||
// Start audio playback + stopwatch simultaneously
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var reader = new AudioFileReader(filePath);
|
|
||||||
_audioOut = new WaveOutEvent();
|
|
||||||
_audioOut.Init(reader);
|
|
||||||
_audioOut.PlaybackStopped += (_, _) => BeginInvoke(StopMusic);
|
|
||||||
_musicStopwatch = Stopwatch.StartNew();
|
|
||||||
_audioOut.Play();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[music] playback failed: {ex.Message}");
|
|
||||||
_musicStopwatch = Stopwatch.StartNew();
|
|
||||||
}
|
|
||||||
|
|
||||||
BeginInvoke(() => _lblTrack.Text = $"Playing 00:00 / {_musicTotalTime:mm\\:ss} {Path.GetFileName(filePath)}");
|
|
||||||
}
|
|
||||||
|
|
||||||
void StopMusic()
|
|
||||||
{
|
|
||||||
if (_audioOut != null)
|
|
||||||
{
|
|
||||||
try { _audioOut.Stop(); } catch { }
|
|
||||||
try { _audioOut.Dispose(); } catch { }
|
|
||||||
_audioOut = null;
|
|
||||||
}
|
|
||||||
_musicStopwatch = null;
|
|
||||||
|
|
||||||
if (_isMusicRunning)
|
|
||||||
{
|
|
||||||
_isMusicRunning = false;
|
|
||||||
if (!IsDisposed)
|
|
||||||
{
|
|
||||||
_lblTrack.Text = "";
|
|
||||||
RefreshButtonStates();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void PopulateAudioDevices()
|
void PopulateAudioDevices()
|
||||||
{
|
{
|
||||||
using var enumerator = new MMDeviceEnumerator();
|
using var enumerator = new MMDeviceEnumerator();
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using FftSharp;
|
using FftSharp;
|
||||||
using NAudio.Wave;
|
|
||||||
|
|
||||||
namespace Substation;
|
namespace Substation;
|
||||||
|
|
||||||
public record MusicPattern(List<WaveFrame> ChannelA, List<WaveFrame> ChannelB, TimeSpan Duration);
|
|
||||||
|
|
||||||
public static class MusicAnalyzer
|
public static class MusicAnalyzer
|
||||||
{
|
{
|
||||||
public const int WindowSize = 2048;
|
public const int WindowSize = 2048;
|
||||||
@@ -15,79 +12,6 @@ public static class MusicAnalyzer
|
|||||||
const double RhythmLow = 20, RhythmHigh = 250;
|
const double RhythmLow = 20, RhythmHigh = 250;
|
||||||
const double MelodyLow = 300, MelodyHigh = 4000;
|
const double MelodyLow = 300, MelodyHigh = 4000;
|
||||||
|
|
||||||
public static MusicPattern Analyze(string mp3Path, IProgress<int>? progress = null)
|
|
||||||
{
|
|
||||||
const int sampleRate = 44100;
|
|
||||||
var samples = DecodeToMono(mp3Path, sampleRate);
|
|
||||||
var duration = TimeSpan.FromSeconds((double)samples.Length / sampleRate);
|
|
||||||
|
|
||||||
var tickCount = (int)Math.Ceiling((double)samples.Length / sampleRate / TickDuration);
|
|
||||||
var channelA = new List<WaveFrame>(tickCount);
|
|
||||||
var channelB = new List<WaveFrame>(tickCount);
|
|
||||||
|
|
||||||
var window = new FftSharp.Windows.Hanning();
|
|
||||||
var buffer = new double[WindowSize];
|
|
||||||
int fftsPerTick = (int)Math.Round(TickDuration * sampleRate / HopSize);
|
|
||||||
|
|
||||||
double[]? prevRhythmMag = null;
|
|
||||||
double maxFlux = 0;
|
|
||||||
double maxMelodyEnergy = 0;
|
|
||||||
|
|
||||||
// First pass: collect per-tick features
|
|
||||||
var tickFeatures = new List<TickFeature>(tickCount);
|
|
||||||
int pos = 0;
|
|
||||||
int fftIndex = 0;
|
|
||||||
|
|
||||||
while (pos + WindowSize <= samples.Length)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < WindowSize; i++)
|
|
||||||
buffer[i] = samples[pos + i];
|
|
||||||
|
|
||||||
window.ApplyInPlace(buffer);
|
|
||||||
var spectrum = FFT.Forward(buffer);
|
|
||||||
var mag = FFT.Magnitude(spectrum);
|
|
||||||
|
|
||||||
var (rhythmEnergy, rhythmFlux, melodyEnergy, melodyFreq) =
|
|
||||||
ExtractFeatures(mag, prevRhythmMag, sampleRate);
|
|
||||||
|
|
||||||
if (rhythmFlux > maxFlux) maxFlux = rhythmFlux;
|
|
||||||
if (melodyEnergy > maxMelodyEnergy) maxMelodyEnergy = melodyEnergy;
|
|
||||||
|
|
||||||
prevRhythmMag = mag;
|
|
||||||
|
|
||||||
int tickIdx = fftIndex / Math.Max(1, fftsPerTick);
|
|
||||||
while (tickFeatures.Count <= tickIdx)
|
|
||||||
tickFeatures.Add(new TickFeature());
|
|
||||||
|
|
||||||
var tf = tickFeatures[tickIdx];
|
|
||||||
tf.RhythmFlux = Math.Max(tf.RhythmFlux, rhythmFlux);
|
|
||||||
tf.MelodyEnergy += melodyEnergy;
|
|
||||||
tf.MelodyFreqSamples.Add(melodyFreq);
|
|
||||||
tf.MelodyCount++;
|
|
||||||
|
|
||||||
pos += HopSize;
|
|
||||||
fftIndex++;
|
|
||||||
|
|
||||||
if (progress != null && fftIndex % 50 == 0)
|
|
||||||
{
|
|
||||||
var pct = (int)((double)pos / samples.Length * 100);
|
|
||||||
progress.Report(pct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progress?.Report(100);
|
|
||||||
|
|
||||||
// Second pass: normalize and build WaveFrames
|
|
||||||
foreach (var tf in tickFeatures)
|
|
||||||
{
|
|
||||||
var (frameA, frameB) = BuildWaveFrame(tf, maxFlux, maxMelodyEnergy);
|
|
||||||
channelA.Add(frameA);
|
|
||||||
channelB.Add(frameB);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new MusicPattern(channelA, channelB, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq)
|
public static (double rhythmEnergy, double rhythmFlux, double melodyEnergy, double melodyFreq)
|
||||||
ExtractFeatures(double[] magnitude, double[]? prevRhythmMag, int sampleRate)
|
ExtractFeatures(double[] magnitude, double[]? prevRhythmMag, int sampleRate)
|
||||||
{
|
{
|
||||||
@@ -147,31 +71,6 @@ public static class MusicAnalyzer
|
|||||||
return Math.Clamp(ms, 10, 1000);
|
return Math.Clamp(ms, 10, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
static double[] DecodeToMono(string mp3Path, int sampleRate)
|
|
||||||
{
|
|
||||||
using var reader = new Mp3FileReader(mp3Path);
|
|
||||||
var format = new WaveFormat(sampleRate, 16, 1);
|
|
||||||
using var resampler = new MediaFoundationResampler(reader, format);
|
|
||||||
resampler.ResamplerQuality = 60;
|
|
||||||
|
|
||||||
var sampleList = new List<float>();
|
|
||||||
var buffer = new byte[sampleRate * 2]; // 1s worth of 16-bit mono
|
|
||||||
int read;
|
|
||||||
while ((read = resampler.Read(buffer, 0, buffer.Length)) > 0)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < read; i += 2)
|
|
||||||
{
|
|
||||||
short sample = (short)(buffer[i] | (buffer[i + 1] << 8));
|
|
||||||
sampleList.Add(sample / 32768f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = new double[sampleList.Count];
|
|
||||||
for (int i = 0; i < sampleList.Count; i++)
|
|
||||||
result[i] = sampleList[i];
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TickFeature
|
public class TickFeature
|
||||||
{
|
{
|
||||||
public double RhythmFlux;
|
public double RhythmFlux;
|
||||||
|
|||||||
Reference in New Issue
Block a user