Implemented default device switching

Added VoiceMeeter plugin (run macro and get status)
Added Inno Setup script
Added Fusion 360 files for housing designs
This commit is contained in:
Mark van Renswoude 2021-03-06 10:53:38 +01:00
parent 197aef531a
commit cae557e7e1
54 changed files with 1971 additions and 31 deletions

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
.vs/
bin/
obj/
Windows/packages/
Windows/Release/
*.user

4
.gitmodules vendored
View File

@ -1,3 +1,7 @@
[submodule "Windows/min.NET"]
path = Windows/min.NET
url = https://github.com/MvRens/min.NET
[submodule "Windows/VoicemeeterRemote"]
path = Windows/VoicemeeterRemote
url = https://github.com/MvRens/VoicemeeterRemote

View File

@ -1,28 +1,47 @@
# Massive Knob
Control audio devices using physical knobs.
Control audio devices using physical knobs. And more.
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:
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. The original requirements were:
**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 volume using a potentiometer (fixed position) instead of a rotary encoder (endless rotation)
2. Control specific audio devices, not the current default device
3. Provide means of switching the default device by pressing a button
Because of the second requirement, a simple media keys HID device does not suffice and extra software is required on the desktop.
Because of these requirements, a simple media keys HID device does not suffice and extra software is required on the desktop. This opens up a range of possibilities.
## Features
- 🔊 Set the volume for specific devices / send the current volume to an analog output
- 🔇 Mute / unmute specific devices / send the muted state to a digital output (*e.g. LED*)
- 🎧 Set the default device / set a digital output based on the default device
- 💬 Optional OSD (On-Screen Display)
- 🔌 VoiceMeeter (Standard, Banana & Potato) plugin to execute macros or read the current state
Massive Knob is basically a host for plugins. A plugin can implement a device or actions which either process signals from the device to perform an action (for example, change the volume when a knob is turned) or send signals to the device based on the system state (for example, light up an LED to indicate the default device).
### Devices
Devices can provide the following inputs and outputs, up to 255 for each type (unless you're Look Mum No Computer I assume this will be enough):
1. Analog input (*e.g. a potentiometer*)
2. Digital input (*e.g. a button or switch*)
3. Analog output (*e.g. a PWM output, though not yet supported by the reference Arduino implementation*)
4. Digital output (*e.g. an LED*)
#### Serial
Connects to a compatible device on a Serial port, probably a USB device like an Arduino. The device must implement the Massive Knob protocol which uses the [MIN protocol](https://github.com/min-protocol/min) to send and receive frames. An Arduino Sketch is included with this repository which can be customized to suit your hardware layout.
#### Emulator
Useful for development, this one emulates an actual device. The number of inputs and outputs are configurable, a popup allows changing the inputs and shows the state of the outputs.
**Nice to have**
1. Physical buttons to switch the active device
- by changing the Windows default output device
- 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.
The Windows software is written in C# using .NET Framework 4.7.2 and Visual Studio 2019.
Refer to the bundled plugins for examples.
Some icons courtesy of https://feathericons.com/

41
Windows/BuildRelease.ps1 Normal file
View File

@ -0,0 +1,41 @@
# Run this script from the Developer PowerShell found in Visual Studio 2019
# or the start menu to get the correct msbuild version on the path
#
# GitVersion is also required and must be available on the path
# Inno Setup 5 is used to compile the setup, it's path is specified below
#
$innoSetupCompiler = "C:\Program Files (x86)\Inno Setup 5\ISCC.exe"
$versionJson = & GitVersion | Out-String
try
{
$version = ConvertFrom-Json $versionJson
}
catch
{
Write-Host "Error while parsing GitVersion output: $($_.Exception.Message)" -ForegroundColor Red
Write-Host $versionJson -ForegroundColor Gray
exit 1
}
Write-Host "GitVersion: $($version.LegacySemVer)"
$env:BUILD_VERSION = $version.LegacySemVer
& msbuild MassiveKnob.sln /t:Clean /t:Build /p:Configuration=Release
if (!$?)
{
Write-Host "MSBuild failed, aborting..." -ForegroundColor Red
exit 1
}
& $innoSetupCompiler "Setup\MassiveKnobSetup.iss"
if (!$?)
{
Write-Host "Inno Setup failed, aborting..." -ForegroundColor Red
exit 1
}

View File

@ -0,0 +1,101 @@
using System;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using MassiveKnob.Plugin.CoreAudio.OSD;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.CoreAudio.GetDefault
{
public class DeviceGetDefaultAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("3c427e28-493f-489f-abb3-1a7ef23ca6c9");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.OutputDigital;
public string Name { get; } = Strings.GetDefaultName;
public string Description { get; } = Strings.GetDefaultDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobActionInstance
{
private IMassiveKnobActionContext actionContext;
private DeviceGetDefaultActionSettings settings;
private IDevice playbackDevice;
private IDisposable deviceChanged;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<DeviceGetDefaultActionSettings>();
ApplySettings();
}
public void Dispose()
{
deviceChanged?.Dispose();
}
private void ApplySettings()
{
if (playbackDevice != null && playbackDevice.Id == settings.DeviceId)
return;
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
deviceChanged?.Dispose();
deviceChanged = playbackDevice?.PropertyChanged.Subscribe(PropertyChanged);
CheckActive();
}
public UserControl CreateSettingsControl()
{
var viewModel = new DeviceGetDefaultActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceGetDefaultActionSettingsView(viewModel);
}
private void PropertyChanged(DevicePropertyChangedArgs args)
{
if (args.ChangedType != DeviceChangedType.DefaultChanged)
return;
CheckActive();
// TODO default OSD
//if (settings.OSD)
//OSDManager.Show(args.Device);
}
private void CheckActive()
{
if (playbackDevice == null)
return;
var isDefault = (settings.Playback && playbackDevice.IsDefaultDevice) ||
(settings.Communications && playbackDevice.IsDefaultCommunicationsDevice);
actionContext.SetDigitalOutput(settings.Inverted ? !isDefault : isDefault);
}
}
}
}

View File

@ -0,0 +1,12 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.GetDefault
{
public class DeviceGetDefaultActionSettings : BaseDeviceSettings
{
public bool Playback { get; set; } = true;
public bool Communications { get; set; } = true;
public bool Inverted { get; set; }
}
}

View File

@ -0,0 +1,28 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.GetDefault.DeviceGetDefaultActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:getDefault="clr-namespace:MassiveKnob.Plugin.CoreAudio.GetDefault"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.CoreAudio"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance getDefault:DeviceGetDefaultActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseDeviceSettingsView />
<TextBlock Margin="0,24,0,0" Text="{x:Static coreAudio:Strings.SettingGetDefaultWhen}" />
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Playback}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetDefaultPlayback}" />
</CheckBox>
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Communications}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetDefaultCommunications}" />
</CheckBox>
<CheckBox Margin="0,24,0,0" IsChecked="{Binding Inverted}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetDefaultInverted}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.GetDefault
{
/// <summary>
/// Interaction logic for DeviceGetDefaultActionSettingsView.xaml
/// </summary>
public partial class DeviceGetDefaultActionSettingsView
{
public DeviceGetDefaultActionSettingsView(DeviceGetDefaultActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,54 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.GetDefault
{
public class DeviceGetDefaultActionSettingsViewModel : BaseDeviceSettingsViewModel<DeviceGetDefaultActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public bool Playback
{
get => Settings.Playback;
set
{
if (value == Settings.Playback)
return;
Settings.Playback = value;
OnPropertyChanged();
}
}
public bool Communications
{
get => Settings.Communications;
set
{
if (value == Settings.Communications)
return;
Settings.Communications = value;
OnPropertyChanged();
}
}
public bool Inverted
{
get => Settings.Inverted;
set
{
if (value == Settings.Inverted)
return;
Settings.Inverted = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceGetDefaultActionSettingsViewModel(DeviceGetDefaultActionSettings settings) : base(settings)
{
}
}
}

View File

@ -17,7 +17,7 @@
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>$(localappdata)\MassiveKnob\Plugins\CoreAudio\</OutputPath>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
@ -49,6 +49,12 @@
<Compile Include="Base\BaseDeviceSettingsView.xaml.cs">
<DependentUpon>BaseDeviceSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="GetDefault\DeviceGetDefaultAction.cs" />
<Compile Include="GetDefault\DeviceGetDefaultActionSettings.cs" />
<Compile Include="GetDefault\DeviceGetDefaultActionSettingsView.xaml.cs">
<DependentUpon>DeviceGetDefaultActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="GetDefault\DeviceGetDefaultActionSettingsViewModel.cs" />
<Compile Include="GetMuted\DeviceGetMutedAction.cs" />
<Compile Include="GetMuted\DeviceGetMutedActionSettings.cs" />
<Compile Include="GetMuted\DeviceGetMutedActionSettingsView.xaml.cs">
@ -61,6 +67,12 @@
</Compile>
<Compile Include="OSD\OSDManager.cs" />
<Compile Include="OSD\OSDWindowViewModel.cs" />
<Compile Include="SetDefault\DeviceSetDefaultAction.cs" />
<Compile Include="SetDefault\DeviceSetDefaultActionSettings.cs" />
<Compile Include="SetDefault\DeviceSetDefaultActionSettingsView.xaml.cs">
<DependentUpon>DeviceSetDefaultActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="SetDefault\DeviceSetDefaultActionSettingsViewModel.cs" />
<Compile Include="SetMuted\DeviceSetMutedAction.cs" />
<Compile Include="SetMuted\DeviceSetMutedActionSettings.cs" />
<Compile Include="SetMuted\DeviceSetMutedActionSettingsView.xaml.cs">
@ -120,6 +132,10 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="GetDefault\DeviceGetDefaultActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="GetMuted\DeviceGetMutedActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
@ -136,6 +152,10 @@
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="SetDefault\DeviceSetDefaultActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="SetMuted\DeviceSetMutedActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
@ -150,5 +170,6 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MassiveKnob.Plugin.CoreAudio.GetDefault;
using MassiveKnob.Plugin.CoreAudio.GetMuted;
using MassiveKnob.Plugin.CoreAudio.GetVolume;
using MassiveKnob.Plugin.CoreAudio.SetDefault;
using MassiveKnob.Plugin.CoreAudio.SetMuted;
using MassiveKnob.Plugin.CoreAudio.SetVolume;
@ -21,8 +23,12 @@ namespace MassiveKnob.Plugin.CoreAudio
{
new DeviceSetVolumeAction(),
new DeviceGetVolumeAction(),
new DeviceSetMutedAction(),
new DeviceGetMutedAction()
new DeviceGetMutedAction(),
new DeviceSetDefaultAction(),
new DeviceGetDefaultAction()
};

View File

@ -0,0 +1,84 @@
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using Microsoft.Extensions.Logging;
namespace MassiveKnob.Plugin.CoreAudio.SetDefault
{
public class DeviceSetDefaultAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("b76f1eb7-2419-42b4-9de4-9bfe6f65a841");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.InputDigital;
public string Name { get; } = Strings.SetDefaultName;
public string Description { get; } = Strings.SetDefaultDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobDigitalAction
{
private IMassiveKnobActionContext actionContext;
private DeviceSetDefaultActionSettings settings;
private IDevice playbackDevice;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<DeviceSetDefaultActionSettings>();
ApplySettings();
}
public void Dispose()
{
}
private void ApplySettings()
{
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
}
public UserControl CreateSettingsControl()
{
var viewModel = new DeviceSetDefaultActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceSetDefaultActionSettingsView(viewModel);
}
public async ValueTask DigitalChanged(bool on)
{
if (playbackDevice == null || !on)
return;
if (settings.Playback)
await playbackDevice.SetAsDefaultAsync();
if (settings.Communications)
await playbackDevice.SetAsDefaultCommunicationsAsync();
// TODO OSD for default device
//if (settings.OSD)
//OSDManager.Show(playbackDevice);
}
}
}
}

View File

@ -0,0 +1,10 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.SetDefault
{
public class DeviceSetDefaultActionSettings : BaseDeviceSettings
{
public bool Playback { get; set; } = true;
public bool Communications { get; set; } = true;
}
}

View File

@ -0,0 +1,23 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.SetDefault.DeviceSetDefaultActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:base="clr-namespace:MassiveKnob.Plugin.CoreAudio.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.CoreAudio"
xmlns:setDefault="clr-namespace:MassiveKnob.Plugin.CoreAudio.SetDefault"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance setDefault:DeviceSetDefaultActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseDeviceSettingsView />
<CheckBox Margin="0,24,0,0" IsChecked="{Binding Playback}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingSetDefaultPlayback}" />
</CheckBox>
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Communications}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingSetDefaultCommunications}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.SetDefault
{
/// <summary>
/// Interaction logic for DeviceSetDefaultActionSettingsView.xaml
/// </summary>
public partial class DeviceSetDefaultActionSettingsView
{
public DeviceSetDefaultActionSettingsView(DeviceSetDefaultActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,41 @@
using MassiveKnob.Plugin.CoreAudio.Base;
namespace MassiveKnob.Plugin.CoreAudio.SetDefault
{
public class DeviceSetDefaultActionSettingsViewModel : BaseDeviceSettingsViewModel<DeviceSetDefaultActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public bool Playback
{
get => Settings.Playback;
set
{
if (value == Settings.Playback)
return;
Settings.Playback = value;
OnPropertyChanged();
}
}
public bool Communications
{
get => Settings.Communications;
set
{
if (value == Settings.Communications)
return;
Settings.Communications = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceSetDefaultActionSettingsViewModel(DeviceSetDefaultActionSettings settings) : base(settings)
{
}
}
}

View File

@ -105,6 +105,24 @@ namespace MassiveKnob.Plugin.CoreAudio {
}
}
/// <summary>
/// Looks up a localized string similar to Sets the digital output depending on whether the selected device is the active playback or communications device..
/// </summary>
public static string GetDefaultDescription {
get {
return ResourceManager.GetString("GetDefaultDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Is default device.
/// </summary>
public static string GetDefaultName {
get {
return ResourceManager.GetString("GetDefaultName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Sets the digital output to the muted state for the selected device, regardless of the current default device..
/// </summary>
@ -159,6 +177,24 @@ namespace MassiveKnob.Plugin.CoreAudio {
}
}
/// <summary>
/// Looks up a localized string similar to Changes the default playback and/or communications device when the input turns on..
/// </summary>
public static string SetDefaultDescription {
get {
return ResourceManager.GetString("SetDefaultDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Set default device.
/// </summary>
public static string SetDefaultName {
get {
return ResourceManager.GetString("SetDefaultName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Toggles the muted state for the selected device, regardless of the current default device..
/// </summary>
@ -177,6 +213,42 @@ namespace MassiveKnob.Plugin.CoreAudio {
}
}
/// <summary>
/// Looks up a localized string similar to is the default communications device.
/// </summary>
public static string SettingGetDefaultCommunications {
get {
return ResourceManager.GetString("SettingGetDefaultCommunications", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Inverted (off when the device is the default).
/// </summary>
public static string SettingGetDefaultInverted {
get {
return ResourceManager.GetString("SettingGetDefaultInverted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to is the default playback device.
/// </summary>
public static string SettingGetDefaultPlayback {
get {
return ResourceManager.GetString("SettingGetDefaultPlayback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Turn on when the selected device.
/// </summary>
public static string SettingGetDefaultWhen {
get {
return ResourceManager.GetString("SettingGetDefaultWhen", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Inverted (off when muted).
/// </summary>
@ -204,6 +276,24 @@ namespace MassiveKnob.Plugin.CoreAudio {
}
}
/// <summary>
/// Looks up a localized string similar to Set as the default communications device.
/// </summary>
public static string SettingSetDefaultCommunications {
get {
return ResourceManager.GetString("SettingSetDefaultCommunications", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Set as the default playback device.
/// </summary>
public static string SettingSetDefaultPlayback {
get {
return ResourceManager.GetString("SettingSetDefaultPlayback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Inverted (muted when off).
/// </summary>

View File

@ -132,6 +132,12 @@
<data name="DeviceDisplayNameUnplugged" xml:space="preserve">
<value>{0} (Unplugged)</value>
</data>
<data name="GetDefaultDescription" xml:space="preserve">
<value>Sets the digital output depending on whether the selected device is the active playback or communications device.</value>
</data>
<data name="GetDefaultName" xml:space="preserve">
<value>Is default device</value>
</data>
<data name="GetMutedDescription" xml:space="preserve">
<value>Sets the digital output to the muted state for the selected device, regardless of the current default device.</value>
</data>
@ -150,12 +156,30 @@
<data name="PluginName" xml:space="preserve">
<value>Windows Core Audio</value>
</data>
<data name="SetDefaultDescription" xml:space="preserve">
<value>Changes the default playback and/or communications device when the input turns on.</value>
</data>
<data name="SetDefaultName" xml:space="preserve">
<value>Set default device</value>
</data>
<data name="SetMutedDescription" xml:space="preserve">
<value>Toggles the muted state for the selected device, regardless of the current default device.</value>
</data>
<data name="SetMutedName" xml:space="preserve">
<value>Mute / unmute</value>
</data>
<data name="SettingGetDefaultCommunications" xml:space="preserve">
<value>is the default communications device</value>
</data>
<data name="SettingGetDefaultInverted" xml:space="preserve">
<value>Inverted (off when the device is the default)</value>
</data>
<data name="SettingGetDefaultPlayback" xml:space="preserve">
<value>is the default playback device</value>
</data>
<data name="SettingGetDefaultWhen" xml:space="preserve">
<value>Turn on when the selected device</value>
</data>
<data name="SettingGetMutedInverted" xml:space="preserve">
<value>Inverted (off when muted)</value>
</data>
@ -165,6 +189,12 @@
<data name="SettingPlaybackDevice" xml:space="preserve">
<value>Playback device</value>
</data>
<data name="SettingSetDefaultCommunications" xml:space="preserve">
<value>Set as the default communications device</value>
</data>
<data name="SettingSetDefaultPlayback" xml:space="preserve">
<value>Set as the default playback device</value>
</data>
<data name="SettingSetMutedSetInverted" xml:space="preserve">
<value>Inverted (muted when off)</value>
</data>

View File

@ -17,7 +17,7 @@
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>$(localappdata)\MassiveKnob\Plugins\EmulatorDevice\</OutputPath>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>

View File

@ -17,7 +17,7 @@
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>$(localappdata)\MassiveKnob\Plugins\SerialDevice\</OutputPath>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>

View File

@ -0,0 +1,13 @@
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter.Base
{
public class BaseVoiceMeeterSettings
{
public RunVoicemeeterParam Version
{
get => InstanceRegister.Version;
set => InstanceRegister.Version = value;
}
}
}

View File

@ -0,0 +1,15 @@
<UserControl x:Class="MassiveKnob.Plugin.VoiceMeeter.Base.BaseVoiceMeeterSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:base="clr-namespace:MassiveKnob.Plugin.VoiceMeeter.Base"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.VoiceMeeter"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance base:BaseVoiceMeeterSettingsViewModel}">
<StackPanel Orientation="Vertical">
<TextBlock Text="{x:Static coreAudio:Strings.SettingVoiceMeeterVersion}" />
<ComboBox Margin="0,4,0,0" ItemsSource="{Binding Versions}" SelectedItem="{Binding SelectedVersion}" DisplayMemberPath="DisplayName" />
</StackPanel>
</UserControl>

View File

@ -0,0 +1,13 @@
namespace MassiveKnob.Plugin.VoiceMeeter.Base
{
/// <summary>
/// Interaction logic for BaseVoiceMeeterSettingsView.xaml
/// </summary>
public partial class BaseVoiceMeeterSettingsView
{
public BaseVoiceMeeterSettingsView()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,84 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter.Base
{
public class BaseVoiceMeeterSettingsViewModel<T> : BaseVoiceMeeterSettingsViewModel where T : BaseVoiceMeeterSettings
{
protected new T Settings => (T)base.Settings;
public BaseVoiceMeeterSettingsViewModel(T settings) : base(settings)
{
}
}
public class BaseVoiceMeeterSettingsViewModel : INotifyPropertyChanged
{
protected readonly BaseVoiceMeeterSettings Settings;
public event PropertyChangedEventHandler PropertyChanged;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public IList<VoiceMeeterVersionViewModel> Versions { get; }
private VoiceMeeterVersionViewModel selectedVersion;
public VoiceMeeterVersionViewModel SelectedVersion
{
get => selectedVersion;
set
{
if (value == selectedVersion)
return;
selectedVersion = value;
OnPropertyChanged();
Settings.Version = value?.Version ?? RunVoicemeeterParam.None;
}
}
// ReSharper restore UnusedMember.Global
public BaseVoiceMeeterSettingsViewModel(BaseVoiceMeeterSettings settings)
{
Settings = settings;
Versions = new List<VoiceMeeterVersionViewModel>
{
new VoiceMeeterVersionViewModel(RunVoicemeeterParam.Voicemeeter, "VoiceMeeter Standard"),
new VoiceMeeterVersionViewModel(RunVoicemeeterParam.VoicemeeterBanana, "VoiceMeeter Banana"),
new VoiceMeeterVersionViewModel(RunVoicemeeterParam.VoicemeeterPotato, "VoiceMeeter Potato")
};
}
public virtual bool IsSettingsProperty(string propertyName)
{
// SelectedVersion already trigger a VoiceMeeterVersionChanged for all instances,
// which causes the settings to be stored
return propertyName != nameof(Versions) && propertyName != nameof(SelectedVersion);
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public class VoiceMeeterVersionViewModel
{
public RunVoicemeeterParam Version { get; }
public string DisplayName { get; }
public VoiceMeeterVersionViewModel(RunVoicemeeterParam version, string displayName)
{
Version = version;
DisplayName = displayName;
}
}
}

View File

@ -0,0 +1,139 @@
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using Microsoft.Extensions.Logging;
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter.GetParameter
{
public class VoiceMeeterGetParameterAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("4904fffb-aaec-4f19-88bb-49f6ed38c3ec");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.OutputDigital;
public string Name { get; } = Strings.GetParameterName;
public string Description { get; } = Strings.GetParameterDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobActionInstance, IVoiceMeeterAction
{
private IMassiveKnobActionContext actionContext;
private VoiceMeeterGetParameterActionSettings settings;
private Parameters parameters;
private IDisposable parameterChanged;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<VoiceMeeterGetParameterActionSettings>();
ApplySettings();
InstanceRegister.Register(this);
}
public void Dispose()
{
InstanceRegister.Unregister(this);
parameterChanged?.Dispose();
parameters?.Dispose();
}
private void ApplySettings()
{
if (InstanceRegister.Version == RunVoicemeeterParam.None)
return;
if (parameters == null)
parameters = new Parameters();
if (string.IsNullOrEmpty(settings.Parameter))
{
parameterChanged?.Dispose();
parameterChanged = null;
}
if (parameterChanged == null)
parameterChanged = parameters.Subscribe(x => ParametersChanged());
// TODO directly update output depending on value
/*
if (playbackDevice != null)
actionContext.SetDigitalOutput(settings.Inverted ? !playbackDevice.IsMuted : playbackDevice.IsMuted);
*/
}
public UserControl CreateSettingsControl()
{
var viewModel = new VoiceMeeterGetParameterActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new VoiceMeeterGetParameterActionSettingsView(viewModel);
}
public void VoiceMeeterVersionChanged()
{
// TODO update viewModel
// TODO reset parameterChanged subscription
actionContext.SetSettings(settings);
}
private void ParametersChanged()
{
if (InstanceRegister.Version == RunVoicemeeterParam.None || string.IsNullOrEmpty(settings.Parameter))
return;
// TODO if another task is already running, wait / chain
// TODO only start task if not yet initialized
Task.Run(async () =>
{
await InstanceRegister.InitializeVoicemeeter();
bool on;
if (float.TryParse(settings.Value, out var settingsFloatValue))
{
try
{
// Even on/off values are returned as floating point "1.000" in text form,
// so try to compare in native format first
var floatValue = global::VoiceMeeter.Remote.GetParameter(settings.Parameter);
on = Math.Abs(settingsFloatValue - floatValue) < 0.001;
}
catch
{
// Fall back to text comparison
var value = global::VoiceMeeter.Remote.GetTextParameter(settings.Parameter);
on = string.Equals(value, settings.Value, StringComparison.InvariantCultureIgnoreCase);
}
}
else
{
var value = global::VoiceMeeter.Remote.GetTextParameter(settings.Parameter);
on = string.Equals(value, settings.Value, StringComparison.InvariantCultureIgnoreCase);
}
// TODO check specific parameter for changes, not just any parameter
actionContext.SetDigitalOutput(settings.Inverted ? !on : on);
});
}
}
}
}

View File

@ -0,0 +1,11 @@
using MassiveKnob.Plugin.VoiceMeeter.Base;
namespace MassiveKnob.Plugin.VoiceMeeter.GetParameter
{
public class VoiceMeeterGetParameterActionSettings : BaseVoiceMeeterSettings
{
public string Parameter { get; set; }
public string Value { get; set; }
public bool Inverted { get; set; }
}
}

View File

@ -0,0 +1,25 @@
<UserControl x:Class="MassiveKnob.Plugin.VoiceMeeter.GetParameter.VoiceMeeterGetParameterActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:getParameter="clr-namespace:MassiveKnob.Plugin.VoiceMeeter.GetParameter"
xmlns:coreAudio="clr-namespace:MassiveKnob.Plugin.VoiceMeeter"
xmlns:base="clr-namespace:MassiveKnob.Plugin.VoiceMeeter.Base"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance getParameter:VoiceMeeterGetParameterActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseVoiceMeeterSettingsView />
<TextBlock Margin="0,24,0,0" Text="{x:Static coreAudio:Strings.SettingGetParameterParameter}" />
<TextBox Margin="0,4,0,0" Text="{Binding Parameter}" />
<TextBlock Margin="0,8,0,0" Text="{x:Static coreAudio:Strings.SettingGetParameterValue}" />
<TextBox Margin="0,4,0,0" Text="{Binding Value}" />
<CheckBox Margin="0,8,0,0" IsChecked="{Binding Inverted}">
<TextBlock Text="{x:Static coreAudio:Strings.SettingGetParameterInverted}" />
</CheckBox>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.VoiceMeeter.GetParameter
{
/// <summary>
/// Interaction logic for VoiceMeeterGetParameterActionSettingsView.xaml
/// </summary>
public partial class VoiceMeeterGetParameterActionSettingsView
{
public VoiceMeeterGetParameterActionSettingsView(VoiceMeeterGetParameterActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,54 @@
using MassiveKnob.Plugin.VoiceMeeter.Base;
namespace MassiveKnob.Plugin.VoiceMeeter.GetParameter
{
public class VoiceMeeterGetParameterActionSettingsViewModel : BaseVoiceMeeterSettingsViewModel<VoiceMeeterGetParameterActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public string Parameter
{
get => Settings.Parameter;
set
{
if (value == Settings.Parameter)
return;
Settings.Parameter = value;
OnPropertyChanged();
}
}
public string Value
{
get => Settings.Value;
set
{
if (value == Settings.Value)
return;
Settings.Value = value;
OnPropertyChanged();
}
}
public bool Inverted
{
get => Settings.Inverted;
set
{
if (value == Settings.Inverted)
return;
Settings.Inverted = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public VoiceMeeterGetParameterActionSettingsViewModel(VoiceMeeterGetParameterActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,7 @@
namespace MassiveKnob.Plugin.VoiceMeeter
{
public interface IVoiceMeeterAction
{
void VoiceMeeterVersionChanged();
}
}

View File

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter
{
public static class InstanceRegister
{
private static readonly object InstancesLock = new object();
private static readonly HashSet<IVoiceMeeterAction> Instances = new HashSet<IVoiceMeeterAction>();
private static Task initializeTask;
// The VoiceMeeter Remote only connects to one instance, so all actions need to be in sync
private static RunVoicemeeterParam version;
public static RunVoicemeeterParam Version
{
get => version;
set
{
if (value == version)
return;
version = value;
Notify(action => action.VoiceMeeterVersionChanged());
initializeTask = Task.Run(async () =>
{
await global::VoiceMeeter.Remote.Initialize(version);
});
}
}
public static Task InitializeVoicemeeter()
{
return initializeTask ?? Task.CompletedTask;
}
public static void Register(IVoiceMeeterAction instance)
{
lock (InstancesLock)
{
Instances.Add(instance);
}
}
public static void Unregister(IVoiceMeeterAction instance)
{
lock (InstancesLock)
{
Instances.Remove(instance);
}
}
public static void Notify(Action<IVoiceMeeterAction> action)
{
lock (InstancesLock)
{
foreach (var instance in Instances)
action(instance);
}
}
}
}

View File

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{19533600-D4F6-4BD4-82A3-C0234FDF044C}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MassiveKnob.Plugin.VoiceMeeter</RootNamespace>
<AssemblyName>MassiveKnob.Plugin.VoiceMeeter</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>$(localappdata)\MassiveKnob\Plugins\VoiceMeeter\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xaml" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Base\BaseVoiceMeeterSettings.cs" />
<Compile Include="Base\BaseVoiceMeeterSettingsView.xaml.cs">
<DependentUpon>BaseVoiceMeeterSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="Base\BaseVoiceMeeterSettingsViewModel.cs" />
<Compile Include="GetParameter\VoiceMeeterGetParameterAction.cs" />
<Compile Include="GetParameter\VoiceMeeterGetParameterActionSettings.cs" />
<Compile Include="GetParameter\VoiceMeeterGetParameterActionSettingsView.xaml.cs">
<DependentUpon>VoiceMeeterGetParameterActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="GetParameter\VoiceMeeterGetParameterActionSettingsViewModel.cs" />
<Compile Include="InstanceRegister.cs" />
<Compile Include="IVoiceMeeterAction.cs" />
<Compile Include="MassiveKnobVoiceMeeterPlugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="RunMacro\VoiceMeeterRunMacroAction.cs" />
<Compile Include="RunMacro\VoiceMeeterRunMacroActionSettings.cs" />
<Compile Include="RunMacro\VoiceMeeterRunMacroActionSettingsView.xaml.cs">
<DependentUpon>VoiceMeeterRunMacroActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="RunMacro\VoiceMeeterRunMacroActionSettingsViewModel.cs" />
<Compile Include="Strings.Designer.cs">
<DependentUpon>Strings.resx</DependentUpon>
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Strings.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Strings.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MassiveKnob.Plugin\MassiveKnob.Plugin.csproj">
<Project>{A1298BE4-1D23-416C-8C56-FC9264487A95}</Project>
<Name>MassiveKnob.Plugin</Name>
</ProjectReference>
<ProjectReference Include="..\VoicemeeterRemote\Voicemeeter\Voicemeeter.csproj">
<Project>{f35dd8e5-91fa-403e-b6f6-8d2b4ae84198}</Project>
<Name>Voicemeeter</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Include="MassiveKnobPlugin.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Page Include="Base\BaseVoiceMeeterSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="GetParameter\VoiceMeeterGetParameterActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="RunMacro\VoiceMeeterRunMacroActionSettingsView.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -0,0 +1,3 @@
{
"EntryAssembly": "MassiveKnob.Plugin.VoiceMeeter.dll"
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using MassiveKnob.Plugin.VoiceMeeter.GetParameter;
using MassiveKnob.Plugin.VoiceMeeter.RunMacro;
namespace MassiveKnob.Plugin.VoiceMeeter
{
[MassiveKnobPlugin]
public class MassiveKnobVoiceMeeterPlugin : IMassiveKnobActionPlugin
{
public Guid PluginId { get; } = new Guid("cf6634f1-97e3-4a18-a4aa-289b558c0e82");
public string Name { get; } = Strings.PluginName;
public string Description { get; } = Strings.PluginDescription;
public string Author { get; } = "Mark van Renswoude <mark@x2software.net>";
public string Url { get; } = "https://www.github.com/MvRens/MassiveKnob/";
public IEnumerable<IMassiveKnobAction> Actions { get; } = new IMassiveKnobAction[]
{
new VoiceMeeterRunMacroAction(),
new VoiceMeeterGetParameterAction()
};
}
}

View File

@ -0,0 +1,36 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MassiveKnob.Plugin.VoiceMeeter")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("MassiveKnob.Plugin.VoiceMeeter")]
[assembly: AssemblyCopyright("Copyright © 2021")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("19533600-d4f6-4bd4-82a3-c0234fdf044c")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View File

@ -0,0 +1,80 @@
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using Microsoft.Extensions.Logging;
using Voicemeeter;
namespace MassiveKnob.Plugin.VoiceMeeter.RunMacro
{
public class VoiceMeeterRunMacroAction : IMassiveKnobAction
{
public Guid ActionId { get; } = new Guid("3bf41e96-9418-4a0e-ba5f-580e0b94dcce");
public MassiveKnobActionType ActionType { get; } = MassiveKnobActionType.InputDigital;
public string Name { get; } = Strings.RunMacroName;
public string Description { get; } = Strings.RunMacroDescription;
public IMassiveKnobActionInstance Create(ILogger logger)
{
return new Instance();
}
private class Instance : IMassiveKnobDigitalAction, IVoiceMeeterAction
{
private IMassiveKnobActionContext actionContext;
private VoiceMeeterRunMacroActionSettings settings;
public void Initialize(IMassiveKnobActionContext context)
{
actionContext = context;
settings = context.GetSettings<VoiceMeeterRunMacroActionSettings>();
InstanceRegister.Register(this);
}
public void Dispose()
{
InstanceRegister.Unregister(this);
}
public UserControl CreateSettingsControl()
{
var viewModel = new VoiceMeeterRunMacroActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
};
return new VoiceMeeterRunMacroActionSettingsView(viewModel);
}
public async ValueTask DigitalChanged(bool on)
{
if (!on)
return;
if (settings.Version == RunVoicemeeterParam.None || string.IsNullOrEmpty(settings.Script))
return;
await InstanceRegister.InitializeVoicemeeter();
global::VoiceMeeter.Remote.SetParameters(settings.Script);
}
public void VoiceMeeterVersionChanged()
{
// TODO update viewModel
actionContext.SetSettings(settings);
}
}
}
}

View File

@ -0,0 +1,9 @@
using MassiveKnob.Plugin.VoiceMeeter.Base;
namespace MassiveKnob.Plugin.VoiceMeeter.RunMacro
{
public class VoiceMeeterRunMacroActionSettings : BaseVoiceMeeterSettings
{
public string Script { get; set; }
}
}

View File

@ -0,0 +1,20 @@
<UserControl x:Class="MassiveKnob.Plugin.VoiceMeeter.RunMacro.VoiceMeeterRunMacroActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:base="clr-namespace:MassiveKnob.Plugin.VoiceMeeter.Base"
xmlns:runMacro="clr-namespace:MassiveKnob.Plugin.VoiceMeeter.RunMacro"
xmlns:voiceMeeter="clr-namespace:MassiveKnob.Plugin.VoiceMeeter"
mc:Ignorable="d"
d:DesignHeight="400" d:DesignWidth="800"
d:DataContext="{d:DesignInstance runMacro:VoiceMeeterRunMacroActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<base:BaseVoiceMeeterSettingsView />
<TextBlock Margin="0,24,0,0" Text="{x:Static voiceMeeter:Strings.SettingRunMacroScript}" />
<TextBox Margin="0,4,0,0" Text="{Binding Path=Script, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Height="200" TextWrapping="Wrap" AcceptsReturn="True" VerticalScrollBarVisibility="Visible" />
<TextBlock Margin="0,4,0,0" Text="{x:Static voiceMeeter:Strings.SettingRunMacroScriptExample}" TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.VoiceMeeter.RunMacro
{
/// <summary>
/// Interaction logic for VoiceMeeterRunMacroActionSettingsView.xaml
/// </summary>
public partial class VoiceMeeterRunMacroActionSettingsView
{
public VoiceMeeterRunMacroActionSettingsView(VoiceMeeterRunMacroActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,29 @@
using MassiveKnob.Plugin.VoiceMeeter.Base;
namespace MassiveKnob.Plugin.VoiceMeeter.RunMacro
{
public class VoiceMeeterRunMacroActionSettingsViewModel : BaseVoiceMeeterSettingsViewModel<VoiceMeeterRunMacroActionSettings>
{
// ReSharper disable UnusedMember.Global - used by WPF Bindingpriv
public string Script
{
get => Settings.Script;
set
{
// TODO timer for change notification
if (value == Settings.Script)
return;
Settings.Script = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
// ReSharper disable once SuggestBaseTypeForParameter - by design
public VoiceMeeterRunMacroActionSettingsViewModel(VoiceMeeterRunMacroActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,172 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 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.
// </auto-generated>
//------------------------------------------------------------------------------
namespace MassiveKnob.Plugin.VoiceMeeter {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// 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", "16.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Strings {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Strings() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MassiveKnob.Plugin.VoiceMeeter.Strings", typeof(Strings).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Turns the output on if the specified parameter equals the specified value..
/// </summary>
public static string GetParameterDescription {
get {
return ResourceManager.GetString("GetParameterDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Get parameter.
/// </summary>
public static string GetParameterName {
get {
return ResourceManager.GetString("GetParameterName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Provides actions to run VoiceMeeter macros or check the current state..
/// </summary>
public static string PluginDescription {
get {
return ResourceManager.GetString("PluginDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to VoiceMeeter Remote.
/// </summary>
public static string PluginName {
get {
return ResourceManager.GetString("PluginName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Runs a VoiceMeeter macro when the input turns on..
/// </summary>
public static string RunMacroDescription {
get {
return ResourceManager.GetString("RunMacroDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Run macro.
/// </summary>
public static string RunMacroName {
get {
return ResourceManager.GetString("RunMacroName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Inverted (on if the parameter does not equal the value).
/// </summary>
public static string SettingGetParameterInverted {
get {
return ResourceManager.GetString("SettingGetParameterInverted", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Parameter name.
/// </summary>
public static string SettingGetParameterParameter {
get {
return ResourceManager.GetString("SettingGetParameterParameter", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Expected value.
/// </summary>
public static string SettingGetParameterValue {
get {
return ResourceManager.GetString("SettingGetParameterValue", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Script.
/// </summary>
public static string SettingRunMacroScript {
get {
return ResourceManager.GetString("SettingRunMacroScript", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Example: Strip[0].A1 = 1; Strip[0].A2 = 0;
///Use comma or semicolons to separate commands, or put each command on a separate line. For more information see the VoiceMeeter documentation on Macro Buttons..
/// </summary>
public static string SettingRunMacroScriptExample {
get {
return ResourceManager.GetString("SettingRunMacroScriptExample", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to VoiceMeeter version.
/// </summary>
public static string SettingVoiceMeeterVersion {
get {
return ResourceManager.GetString("SettingVoiceMeeterVersion", resourceCulture);
}
}
}
}

View File

@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="GetParameterDescription" xml:space="preserve">
<value>Turns the output on if the specified parameter equals the specified value.</value>
</data>
<data name="GetParameterName" xml:space="preserve">
<value>Get parameter</value>
</data>
<data name="PluginDescription" xml:space="preserve">
<value>Provides actions to run VoiceMeeter macros or check the current state.</value>
</data>
<data name="PluginName" xml:space="preserve">
<value>VoiceMeeter Remote</value>
</data>
<data name="RunMacroDescription" xml:space="preserve">
<value>Runs a VoiceMeeter macro when the input turns on.</value>
</data>
<data name="RunMacroName" xml:space="preserve">
<value>Run macro</value>
</data>
<data name="SettingGetParameterInverted" xml:space="preserve">
<value>Inverted (on if the parameter does not equal the value)</value>
</data>
<data name="SettingGetParameterParameter" xml:space="preserve">
<value>Parameter name</value>
</data>
<data name="SettingGetParameterValue" xml:space="preserve">
<value>Expected value</value>
</data>
<data name="SettingRunMacroScript" xml:space="preserve">
<value>Script</value>
</data>
<data name="SettingRunMacroScriptExample" xml:space="preserve">
<value>Example: Strip[0].A1 = 1; Strip[0].A2 = 0;
Use comma or semicolons to separate commands, or put each command on a separate line. For more information see the VoiceMeeter documentation on Macro Buttons.</value>
</data>
<data name="SettingVoiceMeeterVersion" xml:space="preserve">
<value>VoiceMeeter version</value>
</data>
</root>

View File

@ -17,6 +17,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MIN", "min.NET\MIN\MIN.cspr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MIN.SerialPort", "min.NET\MIN.SerialPort\MIN.SerialPort.csproj", "{DB8819EB-D2B7-4AAE-A699-BD200F2C113A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MassiveKnob.Plugin.VoiceMeeter", "MassiveKnob.Plugin.VoiceMeeter\MassiveKnob.Plugin.VoiceMeeter.csproj", "{19533600-D4F6-4BD4-82A3-C0234FDF044C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Voicemeeter", "VoicemeeterRemote\Voicemeeter\Voicemeeter.csproj", "{F35DD8E5-91FA-403E-B6F6-8D2B4AE84198}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -51,6 +55,14 @@ Global
{DB8819EB-D2B7-4AAE-A699-BD200F2C113A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DB8819EB-D2B7-4AAE-A699-BD200F2C113A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DB8819EB-D2B7-4AAE-A699-BD200F2C113A}.Release|Any CPU.Build.0 = Release|Any CPU
{19533600-D4F6-4BD4-82A3-C0234FDF044C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{19533600-D4F6-4BD4-82A3-C0234FDF044C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{19533600-D4F6-4BD4-82A3-C0234FDF044C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{19533600-D4F6-4BD4-82A3-C0234FDF044C}.Release|Any CPU.Build.0 = Release|Any CPU
{F35DD8E5-91FA-403E-B6F6-8D2B4AE84198}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F35DD8E5-91FA-403E-B6F6-8D2B4AE84198}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F35DD8E5-91FA-403E-B6F6-8D2B4AE84198}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F35DD8E5-91FA-403E-B6F6-8D2B4AE84198}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
@ -59,23 +60,56 @@ namespace MassiveKnob.Core
var registeredIds = new RegisteredIds();
var codeBase = Assembly.GetEntryAssembly()?.CodeBase;
if (!string.IsNullOrEmpty(codeBase))
if (string.IsNullOrEmpty(codeBase))
{
var localPath = Path.GetDirectoryName(new Uri(codeBase).LocalPath);
if (!string.IsNullOrEmpty(localPath))
{
var applicationPluginPath = Path.Combine(localPath, @"Plugins");
LoadPlugins(applicationPluginPath, registeredIds, onException);
}
logger.Error("No known EntryAssembly, unable to load plugins");
return;
}
var localPath = Path.GetDirectoryName(new Uri(codeBase).LocalPath);
if (string.IsNullOrEmpty(localPath))
{
logger.Error("EntryAssembly CodeBase does not resolve to a local path, unable to load plugins: {codeBase}", codeBase);
return;
}
var localPluginPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"MassiveKnob", @"Plugins");
var applicationPluginPath = Path.Combine(localPath, @"Plugins");
LoadPlugins(applicationPluginPath, registeredIds, onException);
#if DEBUG
// For debugging, load directly from the various bin folders
// ReSharper disable once InvertIf
if (IsInPath(localPath, "MassiveKnob", "bin", "Debug"))
{
// Go up three folders, filter out lingering bin/Release builds
var solutionPath = Path.GetDirectoryName(Path.GetDirectoryName(Path.GetDirectoryName(localPath)));
LoadPlugins(solutionPath, registeredIds, onException, pluginPath => IsInPath(pluginPath, "bin", "Debug"));
}
#endif
var localPluginPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MassiveKnob", "Plugins");
LoadPlugins(localPluginPath, registeredIds, onException);
}
private static bool IsInPath(string actualPath, params string[] expectedPathComponents)
{
if (string.IsNullOrEmpty(actualPath) || expectedPathComponents.Length == 0)
return false;
var expectedPath = Path.Combine(expectedPathComponents);
if (!actualPath.EndsWith(Path.DirectorySeparatorChar.ToString()))
actualPath += Path.DirectorySeparatorChar;
if (!expectedPath.EndsWith(Path.DirectorySeparatorChar.ToString()))
expectedPath += Path.DirectorySeparatorChar;
return actualPath.EndsWith(expectedPath, StringComparison.CurrentCultureIgnoreCase);
}
private void LoadPlugins(string path, RegisteredIds registeredIds, Action<Exception, string> onException)
private void LoadPlugins(string path, RegisteredIds registeredIds, Action<Exception, string> onException, Func<string, bool> predicate = null)
{
logger.Information("Checking {path} for plugins...", path);
if (!Directory.Exists(path))
@ -89,6 +123,9 @@ namespace MassiveKnob.Core
var pluginPath = Path.GetDirectoryName(metadataFilename);
if (string.IsNullOrEmpty(pluginPath))
continue;
if (predicate != null && !predicate(pluginPath))
continue;
PluginMetadata pluginMetadata;
try

View File

@ -4,7 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Viewbox Stretch="Uniform" x:Key="Analog">
<Viewbox Stretch="Uniform" x:Key="Analog" x:Shared="False">
<Canvas Width="24" Height="24">
<Canvas.Resources>
<ResourceDictionary Source="IconStyle.xaml" />

View File

@ -4,7 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Viewbox Stretch="Uniform" x:Key="Device">
<Viewbox Stretch="Uniform" x:Key="Device" x:Shared="False">
<Canvas Width="24" Height="24">
<Canvas.Resources>
<ResourceDictionary Source="IconStyle.xaml" />

View File

@ -4,7 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Viewbox Stretch="Uniform" x:Key="Digital">
<Viewbox Stretch="Uniform" x:Key="Digital" x:Shared="False">
<Canvas Width="24" Height="24">
<Canvas.Resources>
<ResourceDictionary Source="IconStyle.xaml" />

View File

@ -4,7 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Viewbox Stretch="Uniform" x:Key="Logging">
<Viewbox Stretch="Uniform" x:Key="Logging" x:Shared="False">
<Canvas Width="24" Height="24">
<Path Name="path2" StrokeThickness="2" Stroke="Black" StrokeLineJoin="Round" StrokeStartLineCap="Round" StrokeEndLineCap="Round">
<Path.Data>

View File

@ -4,7 +4,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Viewbox Stretch="Uniform" x:Key="Startup">
<Viewbox Stretch="Uniform" x:Key="Startup" x:Shared="False">
<Canvas Width="24" Height="24">
<Canvas.Resources>
<ResourceDictionary Source="IconStyle.xaml" />

View File

@ -17,6 +17,8 @@ using Serilog.Events;
namespace MassiveKnob.ViewModel
{
// TODO (code quality) split ViewModel for individual views, create viewmodel using container
// TODO (must have) show device status
public class SettingsViewModel : IDisposable, INotifyPropertyChanged
{
private readonly Dictionary<SettingsMenuItem, Type> menuItemControls = new Dictionary<SettingsMenuItem, Type>

View File

@ -0,0 +1,197 @@
#define AppName "Massive Knob"
#define AppVersion GetEnv("BUILD_VERSION")
#define AppPublisher "Mark van Renswoude"
#define AppURL "https://github.com/MvRens/MassiveKnob"
#define AppExeName "MassiveKnob.exe"
#define BasePath ".."
#if AppVersion == ""
#define AppVersion "IDE build"
#endif
[Setup]
AppId={{6D668D73-54E5-4FE1-8028-24FE993627B8}
AppName={#AppName}
AppVersion={#AppVersion}
AppPublisher={#AppPublisher}
AppPublisherURL={#AppURL}
AppSupportURL={#AppURL}
AppUpdatesURL={#AppURL}
DefaultDirName={pf}\{#AppName}
DisableProgramGroupPage=yes
OutputDir={#BasePath}\Release
OutputBaseFilename=MassiveKnobSetup-{#AppVersion}
Compression=lzma
SolidCompression=yes
ArchitecturesInstallIn64BitMode=x64
[Types]
Name: "full"; Description: "Full installation"
Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components]
Name: main; Description: "Massive Knob application"; Types: full custom; Flags: fixed
Name: essentialplugins; Description: "Essential plugins"; Types: full custom
Name: essentialplugins\serialdevice; Description: "Serial device"; Types: full custom
Name: essentialplugins\coreaudio; Description: "Windows Core Audio actions"; Types: full custom
Name: optionalplugins; Description: "Optional plugins"; Types: full custom
Name: optionalplugins\emulatordevice; Description: "Emulator device"; Types: full custom
Name: optionalplugins\voicemeeter; Description: "VoiceMeeter actions"; Types: full custom
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
; Main application
Source: {#BasePath}\MassiveKnob\bin\Release\{#AppExeName}; DestDir: "{app}"; Flags: ignoreversion; Components: main
Source: {#BasePath}\MassiveKnob\bin\Release\{#AppExeName}.config; DestDir: "{app}"; Flags: ignoreversion; Components: main
Source: {#BasePath}\MassiveKnob\bin\Release\*.dll; DestDir: "{app}"; Flags: ignoreversion; Components: main
; Serial device plugin
Source: {#BasePath}\MassiveKnob.Plugin.SerialDevice\bin\Release\MassiveKnobPlugin.json; DestDir: "{app}\Plugins\SerialDevice"; Flags: ignoreversion; Components: essentialplugins\serialdevice
Source: {#BasePath}\MassiveKnob.Plugin.SerialDevice\bin\Release\*.dll; DestDir: "{app}\Plugins\SerialDevice"; Flags: ignoreversion; Components: essentialplugins\serialdevice
; Core Audio plugin
Source: {#BasePath}\MassiveKnob.Plugin.CoreAudio\bin\Release\MassiveKnobPlugin.json; DestDir: "{app}\Plugins\CoreAudio"; Flags: ignoreversion; Components: essentialplugins\coreaudio
Source: {#BasePath}\MassiveKnob.Plugin.CoreAudio\bin\Release\*.dll; DestDir: "{app}\Plugins\CoreAudio"; Flags: ignoreversion; Components: essentialplugins\coreaudio
; Emulator device plugin
Source: {#BasePath}\MassiveKnob.Plugin.EmulatorDevice\bin\Release\MassiveKnobPlugin.json; DestDir: "{app}\Plugins\EmulatorDevice"; Flags: ignoreversion; Components: optionalplugins\emulatordevice
Source: {#BasePath}\MassiveKnob.Plugin.EmulatorDevice\bin\Release\*.dll; DestDir: "{app}\Plugins\EmulatorDevice"; Flags: ignoreversion; Components: optionalplugins\emulatordevice
; VoiceMeeter plugin
Source: {#BasePath}\MassiveKnob.Plugin.VoiceMeeter\bin\Release\MassiveKnobPlugin.json; DestDir: "{app}\Plugins\VoiceMeeter"; Flags: ignoreversion; Components: optionalplugins\voicemeeter
Source: {#BasePath}\MassiveKnob.Plugin.VoiceMeeter\bin\Release\*.dll; DestDir: "{app}\Plugins\VoiceMeeter"; Flags: ignoreversion; Components: optionalplugins\voicemeeter
[Dirs]
Name: "{localappdata}\MassiveKnob"
Name: "{localappdata}\MassiveKnob\Logs"
Name: "{localappdata}\MassiveKnob\Plugins"
[Icons]
Name: "{commonprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{commondesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Run]
;Filename: "{app}\{#AppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
[Code]
// .NET version detection credit goes to:
// https://www.kynosarges.org/DotNetVersion.html
function IsDotNetDetected(version: string; service: cardinal): boolean;
// Indicates whether the specified version and service pack of the .NET Framework is installed.
//
// version -- Specify one of these strings for the required .NET Framework version:
// 'v1.1' .NET Framework 1.1
// 'v2.0' .NET Framework 2.0
// 'v3.0' .NET Framework 3.0
// 'v3.5' .NET Framework 3.5
// 'v4\Client' .NET Framework 4.0 Client Profile
// 'v4\Full' .NET Framework 4.0 Full Installation
// 'v4.5' .NET Framework 4.5
// 'v4.5.1' .NET Framework 4.5.1
// 'v4.5.2' .NET Framework 4.5.2
// 'v4.6' .NET Framework 4.6
// 'v4.6.1' .NET Framework 4.6.1
// 'v4.6.2' .NET Framework 4.6.2
// 'v4.7' .NET Framework 4.7
// 'v4.7.1' .NET Framework 4.7.1
// 'v4.7.2' .NET Framework 4.7.2
// 'v4.8' .NET Framework 4.8
//
// service -- Specify any non-negative integer for the required service pack level:
// 0 No service packs required
// 1, 2, etc. Service pack 1, 2, etc. required
var
key, versionKey: string;
install, release, serviceCount, versionRelease: cardinal;
success: boolean;
begin
versionKey := version;
versionRelease := 0;
// .NET 1.1 and 2.0 embed release number in version key
if version = 'v1.1' then begin
versionKey := 'v1.1.4322';
end else if version = 'v2.0' then begin
versionKey := 'v2.0.50727';
end
// .NET 4.5 and newer install as update to .NET 4.0 Full
else if Pos('v4.', version) = 1 then begin
versionKey := 'v4\Full';
case version of
'v4.5': versionRelease := 378389;
'v4.5.1': versionRelease := 378675; // 378758 on Windows 8 and older
'v4.5.2': versionRelease := 379893;
'v4.6': versionRelease := 393295; // 393297 on Windows 8.1 and older
'v4.6.1': versionRelease := 394254; // 394271 before Win10 November Update
'v4.6.2': versionRelease := 394802; // 394806 before Win10 Anniversary Update
'v4.7': versionRelease := 460798; // 460805 before Win10 Creators Update
'v4.7.1': versionRelease := 461308; // 461310 before Win10 Fall Creators Update
'v4.7.2': versionRelease := 461808; // 461814 before Win10 April 2018 Update
'v4.8': versionRelease := 528040; // 528049 before Win10 May 2019 Update
end;
end;
// installation key group for all .NET versions
key := 'SOFTWARE\Microsoft\NET Framework Setup\NDP\' + versionKey;
// .NET 3.0 uses value InstallSuccess in subkey Setup
if Pos('v3.0', version) = 1 then begin
success := RegQueryDWordValue(HKLM, key + '\Setup', 'InstallSuccess', install);
end else begin
success := RegQueryDWordValue(HKLM, key, 'Install', install);
end;
// .NET 4.0 and newer use value Servicing instead of SP
if Pos('v4', version) = 1 then begin
success := success and RegQueryDWordValue(HKLM, key, 'Servicing', serviceCount);
end else begin
success := success and RegQueryDWordValue(HKLM, key, 'SP', serviceCount);
end;
// .NET 4.5 and newer use additional value Release
if versionRelease > 0 then begin
success := success and RegQueryDWordValue(HKLM, key, 'Release', release);
success := success and (release >= versionRelease);
end;
result := success and (install = 1) and (serviceCount >= service);
end;
function InitializeSetup(): Boolean;
var
response: Integer;
errorCode: Integer;
begin
if not IsDotNetDetected('v4.7.2', 0) then
begin
response := SuppressibleMsgBox('Massive Knob requires Microsoft .NET Framework 4.7.2, which does not appear to be installed.' +
'Do you want to open the .NET download page?'#13#10#13#10 +
'Click No to continue, although Massive Knob may not run properly afterwards, or Cancel to abort the setup.',
mbInformation,
MB_YESNOCANCEL,
IDNO);
case response of
IDYES:
begin
ShellExecAsOriginalUser('open', 'https://dotnet.microsoft.com/download/dotnet-framework/net472', '', '',
SW_SHOWNORMAL, ewNoWait, errorCode);
Result := False;
end;
IDNO:
Result := True;
IDCANCEL:
Result := False;
end;
end else
Result := True;
end;

@ -0,0 +1 @@
Subproject commit 5d259cdaee942029487e37a02e9a32ed9833d80c