diff --git a/Arduino/MassiveKnob/MassiveKnob.ino b/Arduino/MassiveKnob/MassiveKnob.ino new file mode 100644 index 0000000..5169183 --- /dev/null +++ b/Arduino/MassiveKnob/MassiveKnob.ino @@ -0,0 +1,230 @@ +/* + * + * Configuration + * Modify these settings according to your hardware design + * + */ +// Set this to the number of potentiometers you have connected +const byte KnobCount = 1; + +// For each potentiometer, specify the port +const byte KnobPin[KnobCount] = { +// A0, + A1 +}; + +// Minimum time between reporting changing values, reduces serial traffic +const unsigned long MinimumInterval = 50; + +// Alpha value of the Exponential Moving Average (EMA) to reduce noise +const float EMAAlpha = 0.6; + +// How many measurements to take at boot time to seed the EMA +const byte EMASeedCount = 5; + + + + +/* + * + * Le code + * Here be dragons. + * + */ +bool active = false; +enum OutputMode { + Binary, // Communication with the desktop application + PlainText, // Plain text, useful for the Arduino Serial Monitor + Plotter // Graph values, for the Arduino Serial Plotter +}; +OutputMode outputMode = Binary; + +byte volume[KnobCount]; +unsigned long lastChange[KnobCount]; +int analogReadValue[KnobCount]; +float emaValue[KnobCount]; +unsigned long currentTime; +unsigned long lastPlot; + +void setup() +{ + Serial.begin(115200); + + // Wait for the Serial port hardware to initialise + while (!Serial) {} + + + // Seed the moving average + for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++) + emaValue[knobIndex] = analogRead(KnobPin[knobIndex]); + + for (byte seed = 1; seed < EMASeedCount - 1; seed++) + for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++) + getVolume(knobIndex); + + + // Read the initial values + currentTime = millis(); + for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++) + { + volume[knobIndex] = getVolume(knobIndex); + lastChange[knobIndex] = currentTime; + } +} + + +void loop() +{ + if (Serial.available()) + processMessage(Serial.read()); + + // Not that due to ADC checking and Serial communication, currentTime will not be + // accurate throughout the loop. But since we don't need exact timing for the interval this + // is acceptable and saves a few calls to millis. + currentTime = millis(); + + // Check volume knobs + byte newVolume; + for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++) + { + newVolume = getVolume(knobIndex); + + if (newVolume != volume[knobIndex] && (currentTime - lastChange[knobIndex] >= MinimumInterval)) + { + if (active) + // Send out new value + outputVolume(knobIndex, newVolume); + + volume[knobIndex] = newVolume; + lastChange[knobIndex] = currentTime; + } + } + + if (outputMode == Plotter && (currentTime - lastPlot) >= 50) + { + outputPlotter(); + lastPlot = currentTime; + } +} + + +void processMessage(byte message) +{ + switch (message) + { + case 'H': // Handshake + processHandshakeMessage(); + break; + + case 'Q': // Quit + processQuitMessage(); + break; + } +} + + +void processHandshakeMessage() +{ + byte buffer[2]; + if (Serial.readBytes(buffer, 3) < 3) + return; + + if (buffer[0] != 'M' || buffer[1] != 'K') + return; + + switch (buffer[2]) + { + case 'B': + outputMode = Binary; + break; + + case 'P': + outputMode = PlainText; + break; + + case 'G': + outputMode = Plotter; + break; + + default: + return; + } + + + switch (outputMode) + { + case Binary: + Serial.write('H'); + Serial.write(KnobCount); + break; + + case PlainText: + Serial.print("Hello! I have "); + Serial.print(KnobCount); + Serial.println(" knobs."); + break; + } + + active = true; +} + + +void processQuitMessage() +{ + switch (outputMode) + { + case Binary: + case PlainText: + Serial.write('Q'); + break; + } + + active = false; +} + + +byte getVolume(byte knobIndex) +{ + analogReadValue[knobIndex] = analogRead(KnobPin[knobIndex]); + emaValue[knobIndex] = (EMAAlpha * analogReadValue[knobIndex]) + ((1 - EMAAlpha) * emaValue[knobIndex]); + + return map(emaValue[knobIndex], 0, 1023, 0, 100); +} + + +void outputVolume(byte knobIndex, byte newVolume) +{ + switch (outputMode) + { + case Binary: + Serial.write('V'); + Serial.write(knobIndex); + Serial.write(newVolume); + break; + + case PlainText: + Serial.print("Volume #"); + Serial.print(knobIndex); + Serial.print(" = "); + Serial.println(newVolume); + break; + } +} + + +void outputPlotter() +{ + for (byte i = 0; i < KnobCount; i++) + { + if (i > 0) + Serial.print('\t'); + + Serial.print(analogReadValue[i]); + Serial.print('\t'); + Serial.print(emaValue[i]); + Serial.print('\t'); + Serial.print(volume[i]); + } + + Serial.println(); +} diff --git a/README.md b/README.md index 58f8a5b..a248105 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Control audio devices using physical knobs. Inspired by [an article on Prusa's blog](https://blog.prusaprinters.org/3d-print-an-oversized-media-control-volume-knob-arduino-basics_30184/), this project has a slightly different set of goals: **Must have** -1. Control multiple audio devices, one set of physical controls per device -2. Volume is set to an absolute value (potentiometer instead of a rotary encoder) +1. ✔ Control multiple audio devices, one set of physical controls per device +2. ✔ Volume is set to an absolute value (potentiometer instead of a rotary encoder) Because of the second requirement, a simple media keys HID device does not suffice and extra software is required on the desktop. @@ -16,6 +16,8 @@ Because of the second requirement, a simple media keys HID device does not suffi a. by changing the Windows default output device b. by running a VoiceMeeter macro 2. Corresponding LEDs to indicate the currently active device +3. OSD +4. API / plugins to use extra knobs and buttons for other purposes ## Developing The hardware side uses an Arduino sketch to communicate the hardware state over the serial port. diff --git a/Windows/Forms/SettingsForm.Designer.cs b/Windows/Forms/SettingsForm.Designer.cs index 1f00cab..576cb78 100644 --- a/Windows/Forms/SettingsForm.Designer.cs +++ b/Windows/Forms/SettingsForm.Designer.cs @@ -185,7 +185,6 @@ namespace MassiveKnob.Forms this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen; this.Text = "Massive Knob - Settings"; this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.SettingsForm_FormClosing); - this.FormClosed += new System.Windows.Forms.FormClosedEventHandler(this.SettingsForm_FormClosed); this.NotifyIconMenu.ResumeLayout(false); this.CommunicationGroupbox.ResumeLayout(false); this.CommunicationGroupbox.PerformLayout(); diff --git a/Windows/Forms/SettingsForm.cs b/Windows/Forms/SettingsForm.cs index bdc17f1..95dbe9f 100644 --- a/Windows/Forms/SettingsForm.cs +++ b/Windows/Forms/SettingsForm.cs @@ -4,6 +4,8 @@ using System.IO.Ports; using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; +using Dapplo.Windows.Devices; +using Dapplo.Windows.Devices.Enums; using MassiveKnob.Hardware; using MassiveKnob.Settings; using MassiveKnob.UserControls; @@ -11,13 +13,15 @@ using Nito.AsyncEx; namespace MassiveKnob.Forms { - public partial class SettingsForm : Form, IMassiveKnobHardwareObserver + public partial class SettingsForm : Form, IMassiveKnobHardwareObserver, IObserver { private readonly IAudioDeviceManager audioDeviceManager; private readonly IMassiveKnobHardwareFactory massiveKnobHardwareFactory; private readonly List knobDeviceControls = new List(); private bool loading = true; + private string lastConnectedPort = null; + private IDisposable deviceSubscription; private IMassiveKnobHardware hardware; private IAudioDevice[] devices; private Settings.Settings settings; @@ -35,42 +39,43 @@ namespace MassiveKnob.Forms this.massiveKnobHardwareFactory = massiveKnobHardwareFactory; InitializeComponent(); - SerialPortStatusLabel.Text = Strings.StatusNotConnected; - Task.Run(async () => - { - await LoadSettings(); - - await Task.WhenAll( - LoadSerialPorts(), - LoadAudioDevices() - ); - - loading = false; - await Connect(); - }).ContinueWith(t => - { - if (t.IsFaulted && t.Exception != null) - SafeCall(() => throw t.Exception); - }); + // Due to the form not being visible initially (see SetVisibleCore), we can't use the Load event + AsyncLoad(); } - - private void SafeCall(Action action) + + private async void AsyncLoad() + { + await LoadSettings(); + + await Task.WhenAll( + LoadSerialPorts(), + LoadAudioDevices() + ); + + deviceSubscription = DeviceNotification.OnNotification.Subscribe(this); + + loading = false; + await Connect(); + } + + + private void RunInUIContext(Action action) { if (InvokeRequired) Invoke(action); else action(); } - - + + private Task LoadSerialPorts() { var portNames = SerialPort.GetPortNames(); - SafeCall(() => + RunInUIContext(() => { SerialPortCombobox.BeginUpdate(); try @@ -97,22 +102,19 @@ namespace MassiveKnob.Forms private async Task LoadSettings() { var newSettings = await SettingsJsonSerializer.Deserialize(); - SafeCall(() => SetSettings(newSettings)); + RunInUIContext(() => SetSettings(newSettings)); } - private void SaveSettings() + private async Task SaveSettings() { if (settings == null) return; - - Task.Run(async () => - { - using (await saveSettingsLock.LockAsync()) - { - await SettingsJsonSerializer.Serialize(settings); - } - }); + + using (await saveSettingsLock.LockAsync()) + { + await SettingsJsonSerializer.Serialize(settings); + } } @@ -120,9 +122,8 @@ namespace MassiveKnob.Forms { string serialPort = null; - SafeCall(() => + RunInUIContext(() => { - SerialPortStatusLabel.Text = Strings.StatusConnecting; serialPort = (string)SerialPortCombobox.SelectedItem; }); @@ -145,7 +146,7 @@ namespace MassiveKnob.Forms private async Task LoadAudioDevices() { var newDevices = await audioDeviceManager.GetDevices(); - SafeCall(() => SetDevices(newDevices)); + RunInUIContext(() => SetDevices(newDevices)); } @@ -158,10 +159,12 @@ namespace MassiveKnob.Forms SerialPortCombobox.SelectedItem = value.SerialPort; // No need to update the knob device user controls, as they are not loaded yet + // (guaranteed by the order in AsyncLoad) settings = value; } + private void SetDevices(IEnumerable value) { devices = value.ToArray(); @@ -206,13 +209,13 @@ namespace MassiveKnob.Forms if (i < settings.Knobs.Count) knobDeviceControl.SetDeviceId(settings.Knobs[i].DeviceId); - knobDeviceControl.OnDeviceChanged += (sender, args) => + knobDeviceControl.OnDeviceChanged += async (sender, args) => { while (settings.Knobs.Count - 1 < args.KnobIndex) settings.Knobs.Add(new Settings.Settings.KnobSettings()); settings.Knobs[args.KnobIndex].DeviceId = args.DeviceId; - SaveSettings(); + await SaveSettings(); }; knobDeviceControls.Add(knobDeviceControl); @@ -241,7 +244,13 @@ namespace MassiveKnob.Forms { // Prevent the form from showing at startup if (!startupVisibleCalled) + { startupVisibleCalled = true; + + // Make sure the underlying window is still created, otherwise Close won't work + if (!IsHandleCreated) + CreateHandle(); + } else base.SetVisibleCore(value); } @@ -253,16 +262,43 @@ namespace MassiveKnob.Forms } - private void Quit() + private async Task Quit() { + Hide(); + + deviceSubscription?.Dispose(); + + foreach (var knobDeviceControl in knobDeviceControls) + knobDeviceControl.Dispose(); + + knobDeviceControls.Clear(); + + + if (hardware != null) + { + hardware.DetachObserver(this); + await hardware.Disconnect(); + } + + audioDeviceManager?.Dispose(); + closing = true; Close(); } + public void Connecting() + { + RunInUIContext(() => + { + SerialPortStatusLabel.Text = Strings.StatusConnecting; + }); + } + + public void Connected(int knobCount) { - SafeCall(() => + RunInUIContext(() => { SerialPortStatusLabel.Text = Strings.StatusConnected; SetKnobCount(knobCount); @@ -272,14 +308,15 @@ namespace MassiveKnob.Forms public void Disconnected() { - SafeCall(() => + RunInUIContext(() => { SerialPortStatusLabel.Text = Strings.StatusNotConnected; + lastConnectedPort = null; }); } - public void VolumeChanged(int knob, int volume) + public async void VolumeChanged(int knob, int volume) { if (knob >= settings.Knobs.Count) return; @@ -289,15 +326,12 @@ namespace MassiveKnob.Forms var deviceId = settings.Knobs[knob].DeviceId.Value; - Task.Run(async () => + using (await setVolumeLock.LockAsync()) { - using (await setVolumeLock.LockAsync()) - { - var device = await audioDeviceManager.GetDeviceById(deviceId); - if (device != null) - await device.SetVolume(volume); - } - }); + var device = await audioDeviceManager.GetDeviceById(deviceId); + if (device != null) + await device.SetVolume(volume); + } } @@ -310,20 +344,6 @@ namespace MassiveKnob.Forms e.Cancel = true; } - - private void SettingsForm_FormClosed(object sender, FormClosedEventArgs e) - { - foreach (var knobDeviceControl in knobDeviceControls) - knobDeviceControl.Dispose(); - - knobDeviceControls.Clear(); - - - hardware?.DetachObserver(this); - hardware?.Disconnect().GetAwaiter().GetResult(); - audioDeviceManager?.Dispose(); - } - private void NotifyIcon_DoubleClick(object sender, EventArgs e) { @@ -331,9 +351,9 @@ namespace MassiveKnob.Forms } - private void QuitToolStripMenuItem_Click(object sender, EventArgs e) + private async void QuitToolStripMenuItem_Click(object sender, EventArgs e) { - Quit(); + await Quit(); } @@ -349,15 +369,39 @@ namespace MassiveKnob.Forms } - private void SerialPortCombobox_SelectedIndexChanged(object sender, EventArgs e) + private async void SerialPortCombobox_SelectedIndexChanged(object sender, EventArgs e) { - if (loading || (string)SerialPortCombobox.SelectedItem == settings.SerialPort) + var newPort = (string) SerialPortCombobox.SelectedItem; + if (loading || newPort == lastConnectedPort) return; - - settings.SerialPort = (string) SerialPortCombobox.SelectedItem; - SaveSettings(); - Task.Run(Connect); + lastConnectedPort = newPort; + if (settings.SerialPort != newPort) + { + settings.SerialPort = (string) SerialPortCombobox.SelectedItem; + await SaveSettings(); + } + + await Connect(); + } + + + public async void OnNext(DeviceNotificationEvent value) + { + if ((value.EventType == DeviceChangeEvent.DeviceArrival || + value.EventType == DeviceChangeEvent.DeviceRemoveComplete) && + value.Is(DeviceBroadcastDeviceType.DeviceInterface)) + { + await LoadSerialPorts(); + } + } + + public void OnError(Exception error) + { + } + + public void OnCompleted() + { } } } diff --git a/Windows/Hardware/AbstractMassiveKnobHardware.cs b/Windows/Hardware/AbstractMassiveKnobHardware.cs index f576c74..275bf14 100644 --- a/Windows/Hardware/AbstractMassiveKnobHardware.cs +++ b/Windows/Hardware/AbstractMassiveKnobHardware.cs @@ -36,8 +36,15 @@ namespace MassiveKnob.Hardware { observers.Remove(observer); } - + + public void Connecting() + { + foreach (var observer in observers) + observer.Connecting(); + } + + public void Connected(int knobCount) { foreach (var observer in observers) diff --git a/Windows/Hardware/IMassiveKnobHardware.cs b/Windows/Hardware/IMassiveKnobHardware.cs index 03634cb..5421915 100644 --- a/Windows/Hardware/IMassiveKnobHardware.cs +++ b/Windows/Hardware/IMassiveKnobHardware.cs @@ -4,6 +4,7 @@ namespace MassiveKnob.Hardware { public interface IMassiveKnobHardwareObserver { + void Connecting(); void Connected(int knobCount); void Disconnected(); @@ -25,6 +26,6 @@ namespace MassiveKnob.Hardware public interface IMassiveKnobHardwareFactory { - IMassiveKnobHardware Create(string serialPort); + IMassiveKnobHardware Create(string portName); } } diff --git a/Windows/Hardware/MockMassiveKnobHardware.cs b/Windows/Hardware/MockMassiveKnobHardware.cs index 99d8bf0..798bec3 100644 --- a/Windows/Hardware/MockMassiveKnobHardware.cs +++ b/Windows/Hardware/MockMassiveKnobHardware.cs @@ -48,6 +48,7 @@ namespace MassiveKnob.Hardware } + // ReSharper disable once UnusedMember.Global - for testing purposes only public class MockMassiveKnobHardwareFactory : IMassiveKnobHardwareFactory { private readonly int knobCount; @@ -62,7 +63,7 @@ namespace MassiveKnob.Hardware } - public IMassiveKnobHardware Create(string serialPort) + public IMassiveKnobHardware Create(string portName) { return new MockMassiveKnobHardware(knobCount, volumeChangeInterval, maxVolume); } diff --git a/Windows/Hardware/SerialMassiveKnobHardware.cs b/Windows/Hardware/SerialMassiveKnobHardware.cs new file mode 100644 index 0000000..ebd6a41 --- /dev/null +++ b/Windows/Hardware/SerialMassiveKnobHardware.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; +using System.IO.Ports; +using System.Threading; +using System.Threading.Tasks; + +namespace MassiveKnob.Hardware +{ + public class SerialMassiveKnobHardware : AbstractMassiveKnobHardware + { + private readonly string portName; + private Thread workerThread; + + private readonly CancellationTokenSource workerThreadCancellation = new CancellationTokenSource(); + private readonly TaskCompletionSource workerThreadCompleted = new TaskCompletionSource(); + + + public SerialMassiveKnobHardware(string portName) + { + this.portName = portName; + } + + + public override async Task TryConnect() + { + if (workerThread != null) + await Disconnect(); + + workerThread = new Thread(RunWorker) + { + Name = "SerialMassiveKnobHardware Worker" + }; + workerThread.Start(); + } + + + public override async Task Disconnect() + { + workerThreadCancellation.Cancel(); + await workerThreadCompleted.Task; + + workerThread = null; + } + + + private void RunWorker() + { + Observers.Connecting(); + + while (!workerThreadCancellation.IsCancellationRequested) + { + SerialPort serialPort = null; + + void SafeCloseSerialPort() + { + try + { + serialPort?.Dispose(); + } + catch + { + // ignroed + } + + serialPort = null; + Observers.Disconnected(); + Observers.Connecting(); + } + + + var knobCount = 0; + + while (serialPort == null && !workerThreadCancellation.IsCancellationRequested) + { + try + { + serialPort = new SerialPort(portName, 115200); + serialPort.Open(); + + // Send handshake + serialPort.Write(new[] { 'H', 'M', 'K', 'B' }, 0, 4); + + // Wait for reply + serialPort.ReadTimeout = 1000; + var response = serialPort.ReadByte(); + + if ((char) response == 'H') + { + knobCount = serialPort.ReadByte(); + if (knobCount > -1) + break; + } + + SafeCloseSerialPort(); + Thread.Sleep(500); + } + catch + { + SafeCloseSerialPort(); + Thread.Sleep(500); + } + } + + if (workerThreadCancellation.IsCancellationRequested) + { + SafeCloseSerialPort(); + break; + } + + var processingMessage = false; + + Debug.Assert(serialPort != null, nameof(serialPort) + " != null"); + serialPort.DataReceived += (sender, args) => + { + if (args.EventType != SerialData.Chars || processingMessage) + return; + + var senderPort = (SerialPort) sender; + processingMessage = true; + try + { + var message = (char) senderPort.ReadByte(); + ProcessMessage(senderPort, message); + } + finally + { + processingMessage = false; + } + }; + + + Observers.Connected(knobCount); + try + { + // This is where sending data to the hardware would be implemented + while (serialPort.IsOpen && !workerThreadCancellation.IsCancellationRequested) + { + Thread.Sleep(10); + } + } + catch + { + // ignored + } + + Observers.Disconnected(); + SafeCloseSerialPort(); + + if (!workerThreadCancellation.IsCancellationRequested) + Thread.Sleep(500); + } + + workerThreadCompleted.TrySetResult(true); + } + + + private void ProcessMessage(SerialPort serialPort, char message) + { + switch (message) + { + case 'V': + var knobIndex = (byte)serialPort.ReadByte(); + var volume = (byte)serialPort.ReadByte(); + + if (knobIndex < 255 && volume <= 100) + Observers.VolumeChanged(knobIndex, volume); + + break; + } + } + } + + + public class SerialMassiveKnobHardwareFactory : IMassiveKnobHardwareFactory + { + public IMassiveKnobHardware Create(string portName) + { + return new SerialMassiveKnobHardware(portName); + } + } +} diff --git a/Windows/MassiveKnob.csproj b/Windows/MassiveKnob.csproj index 998b322..7655aa1 100644 --- a/Windows/MassiveKnob.csproj +++ b/Windows/MassiveKnob.csproj @@ -65,6 +65,7 @@ + @@ -116,6 +117,12 @@ 4.0.0-alpha5 + + 0.11.24 + + + 0.11.24 + 12.0.3 diff --git a/Windows/MassiveKnob.sln.DotSettings b/Windows/MassiveKnob.sln.DotSettings new file mode 100644 index 0000000..584d2d0 --- /dev/null +++ b/Windows/MassiveKnob.sln.DotSettings @@ -0,0 +1,2 @@ + + UI \ No newline at end of file diff --git a/Windows/Program.cs b/Windows/Program.cs index f5819b8..8f2f1f3 100644 --- a/Windows/Program.cs +++ b/Windows/Program.cs @@ -35,7 +35,8 @@ namespace MassiveKnob container.Register(); // For testing without the hardware: - container.Register(() => new MockMassiveKnobHardwareFactory(3, TimeSpan.FromSeconds(1), 25)); + container.Register(); + //container.Register(() => new MockMassiveKnobHardwareFactory(3, TimeSpan.FromSeconds(1), 25)); return container; }