Skip to content

Commit dd016ef

Browse files
committed
feat: add level meters display
1 parent 34761e6 commit dd016ef

File tree

6 files changed

+225
-46
lines changed

6 files changed

+225
-46
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// This file is part of the VoiceMeeterPlugin project.
2+
//
3+
// Copyright (c) 2024 Dominic Ris
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
namespace Loupedeck.VoiceMeeterPlugin.Actions;
24+
25+
using System.Reactive.Linq;
26+
using System.Reactive.Subjects;
27+
28+
using Extensions;
29+
30+
using Helpers;
31+
32+
using Library.Voicemeeter;
33+
34+
using Services;
35+
36+
using SkiaSharp;
37+
38+
public class LevelsCommand : ActionEditorCommand
39+
{
40+
private VoiceMeeterService VmService { get; }
41+
private Subject<Boolean> OnDestroy { get; } = new();
42+
43+
public LevelsCommand()
44+
{
45+
this.DisplayName = "Level Display";
46+
this.Description = "Displays specific Levels";
47+
48+
this.ActionEditor.AddControlEx(
49+
new ActionEditorTextbox("name", "Display Name", "Name displayed on the device itself").SetRequired()
50+
);
51+
this.ActionEditor.AddControlEx(
52+
new ActionEditorSlider("channel_number", "Channel Number", "The Channel Number to display").SetRequired().SetValues(0, 100, 0, 1)
53+
);
54+
this.ActionEditor.AddControlEx(
55+
new ActionEditorListbox("channel_type", "Channel Type", "The Channel Type to display").SetRequired()
56+
);
57+
this.ActionEditor.AddControlEx(
58+
new ActionEditorTextbox("bgcolor", "Background Color", "The color it should use in hex (#rrggbb example: #FF0000 = red)").SetRegex("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")
59+
);
60+
this.ActionEditor.AddControlEx(
61+
new ActionEditorTextbox("fgcolor", "Foreground Color", "The color it should use in hex (#rrggbb example: #FF0000 = red)").SetRegex("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")
62+
);
63+
64+
this.ActionEditor.ListboxItemsRequested += (_, e) =>
65+
{
66+
// iterate over LevelType enum values and add them (their name) to the listbox
67+
foreach (var value in Enum.GetValues(typeof(LevelType)))
68+
{
69+
e.Items.Add(new ActionEditorListboxItem("channel_type_" + value, value.ToString(), ""));
70+
}
71+
};
72+
73+
this.VmService = VoiceMeeterService.Instance;
74+
}
75+
76+
protected override Boolean OnLoad()
77+
{
78+
this.VmService.Levels
79+
.TakeUntil(this.OnDestroy)
80+
.Subscribe(_ => this.ActionImageChanged());
81+
82+
return base.OnLoad();
83+
}
84+
85+
protected override Boolean OnUnload()
86+
{
87+
this.OnDestroy.OnNext(true);
88+
return base.OnUnload();
89+
}
90+
91+
protected override String GetCommandDisplayName(ActionEditorActionParameters actionParameters)
92+
{
93+
Tuple<String, Levels.Channel, SKColor, SKColor> parameters;
94+
try
95+
{
96+
parameters = GetParameters(actionParameters);
97+
}
98+
catch (Exception)
99+
{
100+
return null;
101+
}
102+
103+
if (parameters == null)
104+
{
105+
return null;
106+
}
107+
108+
var (name, channel, bgColor, fgColor) = parameters;
109+
110+
this.VmService.Levels.AddChannel(channel);
111+
112+
var currentValue = 0f;
113+
114+
try
115+
{
116+
currentValue = Remote.GetLevel(channel.LevelType, channel.ChannelNumber);
117+
}
118+
catch (Exception)
119+
{
120+
// ignore
121+
}
122+
123+
currentValue = (Single)Math.Round(currentValue, 10);
124+
125+
return $"{name} - {currentValue:P0}";
126+
}
127+
128+
protected override BitmapImage GetCommandImage(ActionEditorActionParameters actionParameters, Int32 imageWidth, Int32 imageHeight)
129+
{
130+
Tuple<String, Levels.Channel, SKColor, SKColor> parameters;
131+
try
132+
{
133+
parameters = GetParameters(actionParameters);
134+
}
135+
catch (Exception)
136+
{
137+
return null;
138+
}
139+
140+
if (parameters == null)
141+
{
142+
return null;
143+
}
144+
145+
var (name, channel, bgColor, fgColor) = parameters;
146+
147+
this.VmService.Levels.AddChannel(channel);
148+
149+
var currentValue = 0f;
150+
151+
try
152+
{
153+
currentValue = Remote.GetLevel(channel.LevelType, channel.ChannelNumber);
154+
}
155+
catch (Exception)
156+
{
157+
// ignore
158+
}
159+
160+
currentValue = (Single)Math.Round(currentValue, 10);
161+
162+
163+
return DrawingHelper.DrawVolumeBar(PluginImageSize.Width60, bgColor.ToBitmapColor(), fgColor.ToBitmapColor(), currentValue, 0, 1, 1, "", name, false);
164+
}
165+
166+
private static Tuple<String, Levels.Channel, SKColor, SKColor> GetParameters(ActionEditorActionParameters actionParameters)
167+
{
168+
actionParameters.TryGetString("name", out var name);
169+
actionParameters.TryGetInt32("channel_number", out var channelNumber);
170+
actionParameters.TryGetString("channel_type", out var channelType);
171+
actionParameters.TryGetString("bgcolor", out var bgColor);
172+
actionParameters.TryGetString("fgcolor", out var fgColor);
173+
174+
// for the channel type, we first have to remove the prefix
175+
var type = channelType.Replace("channel_type_", "");
176+
if (!Enum.TryParse<LevelType>(type, out var levelType))
177+
{
178+
return null;
179+
}
180+
181+
var channel = new Levels.Channel { LevelType = levelType, ChannelNumber = channelNumber };
182+
183+
return new Tuple<String, Levels.Channel, SKColor, SKColor>(
184+
String.IsNullOrEmpty(name) ? "Unknown" : name,
185+
channel,
186+
SKColor.TryParse(bgColor, out var bg) ? bg : ColorHelper.Inactive,
187+
SKColor.TryParse(fgColor, out var fg) ? fg : SKColors.White);
188+
}
189+
}

src/VoiceMeeterPlugin/Helpers/DrawingHelper.cs

+5-2
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ public static BitmapImage DrawDefaultImage(String innerText, String outerText, S
124124
}
125125

126126
public static BitmapImage DrawVolumeBar(PluginImageSize imageSize, BitmapColor backgroundColor, BitmapColor foregroundColor, Single currentValue, Int32 minValue, Int32 maxValue,
127-
Int32 scaleFactor, String cmd, String name = "")
127+
Int32 scaleFactor, String cmd, String name = "", Boolean drawValue = true)
128128
{
129129
// Prepare variables
130130
var dim = imageSize.GetDimension();
@@ -144,7 +144,10 @@ public static BitmapImage DrawVolumeBar(PluginImageSize imageSize, BitmapColor b
144144
builder.FillRectangle(xCenter, yCenter, width, -calculatedHeight, backgroundColor);
145145

146146
// Draw value text at the center
147-
builder.DrawText((currentValue / scaleFactor).ToString(CultureInfo.CurrentCulture), foregroundColor);
147+
if (drawValue)
148+
{
149+
builder.DrawText((currentValue / scaleFactor).ToString(CultureInfo.CurrentCulture), foregroundColor);
150+
}
148151

149152
const Int32 fontSize = 16;
150153

src/VoiceMeeterPlugin/Library/Voicemeeter/Levels.cs

+21-18
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,37 @@ public class Channel
2020
};
2121

2222
private readonly List<Channel> _channels;
23-
private readonly List<IObserver<Single[]>> _observers = new();
23+
private readonly List<IObserver<Single[]>> _observers = [];
2424
private readonly IObservable<Int32> _timer;
2525
private IDisposable _timerSubscription;
2626

27-
public Levels(Channel[] channels, Int32 milliseconds = 20)
27+
public Levels(Int32 milliseconds = 20)
2828
{
29-
this._channels = new List<Channel>(channels);
29+
this._channels = [];
3030
this._timer = Observable.Interval(TimeSpan.FromMilliseconds(milliseconds)).Select(_ => 1);
3131
this.Watch();
3232
}
33+
34+
public void AddChannel(Channel channel)
35+
{
36+
// first check if there's already a channel with the same LevelType and ChannelNumber
37+
if (this._channels.Any(c => c.LevelType == channel.LevelType && c.ChannelNumber == channel.ChannelNumber))
38+
{
39+
return;
40+
}
41+
42+
this._channels.Add(channel);
43+
}
3344

3445
private void Watch() =>
3546
this._timerSubscription = this._timer.Subscribe(_ =>
3647
{
37-
var values = new List<Single>(this._channels.Count);
38-
foreach (var channel in this._channels)
48+
if (this._channels.Count == 0)
3949
{
40-
values.Add(Remote.GetLevel(channel.LevelType, channel.ChannelNumber));
50+
return;
4151
}
52+
var values = new List<Single>(this._channels.Count);
53+
values.AddRange(this._channels.Select(channel => Remote.GetLevel(channel.LevelType, channel.ChannelNumber)));
4254

4355
this.Notify(values.ToArray());
4456
});
@@ -63,22 +75,13 @@ private void Notify(Single[] values)
6375

6476
public void Dispose() => this._timerSubscription?.Dispose();
6577

66-
private sealed class Unsubscriber : IDisposable
78+
private sealed class Unsubscriber(List<IObserver<Single[]>> observers, IObserver<Single[]> observer) : IDisposable
6779
{
68-
private readonly List<IObserver<Single[]>> _observers;
69-
private readonly IObserver<Single[]> _observer;
70-
71-
public Unsubscriber(List<IObserver<Single[]>> observers, IObserver<Single[]> observer)
72-
{
73-
this._observers = observers;
74-
this._observer = observer;
75-
}
76-
7780
public void Dispose()
7881
{
79-
if (this._observer != null && this._observers.Contains(this._observer))
82+
if (observer != null && observers.Contains(observer))
8083
{
81-
this._observers.Remove(this._observer);
84+
observers.Remove(observer);
8285
}
8386
}
8487
}

src/VoiceMeeterPlugin/Library/Voicemeeter/Parameters.cs

+4-13
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
/// </summary>
1414
public class Parameters : IDisposable, IObservable<Int32>
1515
{
16-
private readonly List<IObserver<Int32>> _observers = new();
16+
private readonly List<IObserver<Int32>> _observers = [];
1717
private readonly IObservable<Int32> _timer;
1818
private IDisposable _timerSubscription;
1919

@@ -59,22 +59,13 @@ private void Notify(Int32 value)
5959

6060
public void Dispose() => this._timerSubscription?.Dispose();
6161

62-
private sealed class Unsubscriber : IDisposable
62+
private sealed class Unsubscriber(List<IObserver<Int32>> observers, IObserver<Int32> observer) : IDisposable
6363
{
64-
private readonly List<IObserver<Int32>> _observers;
65-
private readonly IObserver<Int32> _observer;
66-
67-
public Unsubscriber(List<IObserver<Int32>> observers, IObserver<Int32> observer)
68-
{
69-
this._observers = observers;
70-
this._observer = observer;
71-
}
72-
7364
public void Dispose()
7465
{
75-
if (this._observer != null && this._observers.Contains(this._observer))
66+
if (observer != null && observers.Contains(observer))
7667
{
77-
this._observers.Remove(this._observer);
68+
observers.Remove(observer);
7869
}
7970
}
8071
}

src/VoiceMeeterPlugin/Library/Voicemeeter/VoicemeeterClient.cs

+4-13
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public void Dispose()
1414
}
1515
}
1616

17-
private readonly List<IObserver<Single>> _observers = new();
17+
private readonly List<IObserver<Single>> _observers = [];
1818

1919
public IDisposable Subscribe(IObserver<Single> observer)
2020
{
@@ -34,22 +34,13 @@ private void Notify(Single value)
3434
}
3535
}
3636

37-
private sealed class Unsubscriber : IDisposable
37+
private sealed class Unsubscriber(List<IObserver<Single>> observers, IObserver<Single> observer) : IDisposable
3838
{
39-
private readonly List<IObserver<Single>> _observers;
40-
private readonly IObserver<Single> _observer;
41-
42-
public Unsubscriber(List<IObserver<Single>> observers, IObserver<Single> observer)
43-
{
44-
this._observers = observers;
45-
this._observer = observer;
46-
}
47-
4839
public void Dispose()
4940
{
50-
if (this._observer != null && this._observers.Contains(this._observer))
41+
if (observer != null && observers.Contains(observer))
5142
{
52-
this._observers.Remove(this._observer);
43+
observers.Remove(observer);
5344
}
5445
}
5546
}

src/VoiceMeeterPlugin/Services/VoiceMeeterService.cs

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public sealed class VoiceMeeterService
99
private static readonly Lazy<VoiceMeeterService> Lazy = new(() => new VoiceMeeterService());
1010

1111
public Parameters Parameters { get; set; }
12+
public Levels Levels { get; set; }
1213
public Boolean Connected { get; set; }
1314

1415
public async Task StartService(ClientApplication application)
@@ -17,6 +18,7 @@ public async Task StartService(ClientApplication application)
1718

1819
this.Connected = true;
1920
this.Parameters = new Parameters();
21+
this.Levels = new Levels();
2022
}
2123
}
2224
}

0 commit comments

Comments
 (0)