Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cloned Playback Volume data model module into Capture module #175

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
using System;
using System.Collections.Generic;
using Artemis.Core;
using Artemis.Core.Modules;
using Artemis.Plugins.Audio.DataModelExpansion.DataModels;
using Artemis.Plugins.Audio.Services;
using NAudio.CoreAudioApi;
using Serilog;

namespace Artemis.Plugins.Audio.DataModelExpansion
{
public abstract class AudioEndpointVolumeModule : Module<AudioEndpointVolumeDataModel>
{
public override List<IModuleActivationRequirement> ActivationRequirements => null;

#region Constructor

public AudioEndpointVolumeModule(ILogger logger, NAudioDeviceEnumerationService naudioDeviceEnumerationService, NAudio.CoreAudioApi.Role role, NAudio.CoreAudioApi.DataFlow flow)
{
_logger = logger;
_naudioDeviceEnumerationService = naudioDeviceEnumerationService;
_role = role;
_flow = flow;
}

#endregion

#region Properties & Fields

internal readonly NAudioDeviceEnumerationService _naudioDeviceEnumerationService;
internal readonly ILogger _logger;
internal readonly object _audioEventLock = new();
internal readonly List<DynamicChild<AudioChannelDataModel>> _channelsDataModels = new();
internal readonly NAudio.CoreAudioApi.DataFlow _flow;
internal readonly NAudio.CoreAudioApi.Role _role;
internal bool _audioDeviceChanged;
internal float _lastMasterPeakVolumeNormalized;
internal MMDevice _audioDevice;
internal AudioEndpointVolume _audioEndpointVolume;

#endregion

#region Plugin Methods

public abstract override DataModelPropertyAttribute GetDataModelDescription();

public override void Enable()
{
_naudioDeviceEnumerationService.NotificationClient.DefaultDeviceChanged += NotificationClient_DefaultDeviceChanged;
UpdateAudioEndpointDevice(true);

// We don't need mor than ~30 updates per second. It will keep CPU usage controlled. 60 or more updates per second could rise cpu usage
AddTimedUpdate(TimeSpan.FromMilliseconds(33), UpdatePeakVolume, "UpdatePeakVolume");
}

public override void Disable()
{
_naudioDeviceEnumerationService.NotificationClient.DefaultDeviceChanged -= NotificationClient_DefaultDeviceChanged;
_audioEndpointVolume?.Dispose();
_audioEndpointVolume = null;
FreeAudioEndpointDevice();
}

public override void Update(double deltaTime)
{
DataModel.TimeSinceLastSound += TimeSpan.FromSeconds(deltaTime);
if (_audioDeviceChanged) UpdateAudioEndpointDevice();
}

#endregion

#region Update DataModel Methods

private void UpdatePeakVolume(double deltaTime)
{
if (IsEnabled == false)
{
// To avoid null object exception on _enumerator use after plugin is disabled.
return;
}

// If no one one is using this DataModel, don't update this part.
if (DataModel.ActivePaths.Count < 1)
{
return;
}

if (_audioDevice == null)
{
// To avoid null object exception on device change or don't update if there are no devices at all
return;
}

// Update Main volume Peak
lock (_audioEventLock) // To avoid query an Device/EndPoint that is not the current device anymore or has more or less channels
{
// Absolute master peak volume
float peakVolumeNormalized = _audioDevice?.AudioMeterInformation.MasterPeakValue ?? 0f;

// Don't update datamodel if not neeeded
if (Math.Abs(_lastMasterPeakVolumeNormalized - peakVolumeNormalized) < 0.00001f)
return;

// Sound detected. Reset timespan
if (Math.Abs(_lastMasterPeakVolumeNormalized - 0.0) > 0.00001f) DataModel.TimeSinceLastSound = TimeSpan.Zero;

DataModel.PeakVolumeNormalized = _lastMasterPeakVolumeNormalized = peakVolumeNormalized;
DataModel.PeakVolume = peakVolumeNormalized * 100f;

// Master peak volume relative to master volume
DataModel.PeakVolumeRelativeNormalized = peakVolumeNormalized * DataModel.VolumeNormalized;
DataModel.PeakVolumeRelative = peakVolumeNormalized * 100f * DataModel.VolumeNormalized;

// Update Channels Peak
AudioMeterInformationChannels channelsVolumeNormalized = _audioDevice?.AudioMeterInformation.PeakValues;

//One more check because AudioEndpoint device can be null any time (device for example). If this is the case, just keep the actual values and update in the next update.
if (channelsVolumeNormalized == null)
return;

for (int i = 0; i < _channelsDataModels.Count && i < channelsVolumeNormalized.Count; i++)
{
DynamicChild<AudioChannelDataModel> audioChannelDataModel = _channelsDataModels[i];
audioChannelDataModel.Value.PeakVolumeNormalized = channelsVolumeNormalized[i];
audioChannelDataModel.Value.PeakVolume = channelsVolumeNormalized[i] * 100f;
}
}
}

private void UpdateVolumeDataModel()
{
DataModel.VolumeChanged.Trigger();
DataModel.VolumeNormalized = _audioEndpointVolume.MasterVolumeLevelScalar;
DataModel.Volume = DataModel.VolumeNormalized * 100f;
DataModel.ChannelCount = _audioEndpointVolume.Channels.Count;
DataModel.DeviceState = _audioDevice.State;
DataModel.Muted = _audioEndpointVolume.Mute;

lock (_audioEventLock)
{
for (int i = 0; i < _channelsDataModels.Count; i++)
{
DynamicChild<AudioChannelDataModel> audioChannelDataModel = _channelsDataModels[i];
float volumeNormalized = _audioEndpointVolume.Channels[i].VolumeLevelScalar;
audioChannelDataModel.Value.VolumeNormalized = volumeNormalized;
audioChannelDataModel.Value.Volume = volumeNormalized * 100f;
}
}
}

private void PopulateChannels()
{
DataModel.Channels.ClearDynamicChildren();
_logger.Verbose($"AudioEndpoint device {_audioDevice.FriendlyName} channel list cleared");
_logger.Verbose($"Preparing to populate {_audioEndpointVolume.Channels.Count} channels for device {_audioDevice.FriendlyName}");
_channelsDataModels.Clear();
for (int i = 0; i < _audioEndpointVolume.Channels.Count; i++)
{
_channelsDataModels.Add(
DataModel.Channels.AddDynamicChild(i.ToString(), new AudioChannelDataModel { ChannelIndex = i }, $"Channel {i}")
);
_logger.Verbose($"AudioEndpoint device {_audioDevice.FriendlyName} channel {i} populated");
}
}

#endregion

#region Audio Management methods

private void NotificationClient_DefaultDeviceChanged()
{
_audioDeviceChanged = true;
// Workarround. MMDevice won't dispose if Dispose() is called from
// non parent thread and NaudioNotificationClient callbacks come from another thread.
// We will use Update() mrhod to dispose MMDevice from creator thread because this (NotificationClient_DefaultDeviceChanged()) is called from another thread
}

private void UpdateAudioEndpointDevice(bool firstRun = false)
{
lock (_audioEventLock)
{
if (!firstRun) FreeAudioEndpointDevice();

if (SetAudioEndpointDevice())
{
PopulateChannels();
_audioDeviceChanged = false;
UpdateVolumeDataModel();
}
}
}

private void FreeAudioEndpointDevice()
{
string disposingAudioEndpointDeviceFriendlyName = _audioDevice?.FriendlyName ?? "Unknown";
_audioDevice?.Dispose();
_audioDevice = null;
_logger.Verbose($"AudioEndpoint device {disposingAudioEndpointDeviceFriendlyName} unregistered as source device to fill AudioEndpoint volume data model");
DataModel.Reset();
}

private bool SetAudioEndpointDevice()
{
_audioDevice = _naudioDeviceEnumerationService.GetDefaultAudioEndpoint(DataFlow.Render, Role.Console);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_audioDevice = _naudioDeviceEnumerationService.GetDefaultAudioEndpoint(_flow, _role);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way at least the module will correctly capture the mic. Unfortunately, the volume doesn't seem to update unless i also have the native windows mixer open:

Artemis.UI.Windows_e0ZRENbMP9.mp4

I'm not sure if we need to listen for some event or call some update function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, missed that one (_flow/_role). That's a major issue. Fix implemented.

In regards to the volume not updating, I can confirm this issue occurs on my system. My suspicion is that nAudio is registering the device and pulling in data - but not actually opening access to the device itself, in regards to the security context. If you note the icon in the system tray for "Apps using your microphone", it will appear when the Windows recording device panel is enabled, and disappear when it is not. The same occurs if you use the Settings sound panel (the "replacement" control panel), or (for another example) if you have a Discord call open. I'll do a little more digging.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, we do have to initialise a recording access in order for the volume data to be updated. This was outside my use case, as I only wanted the mute state or otherwise; but better to do it right the first time.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my experience users don't like it when apps listen to the mic 24/7 so I would be careful with how you implement this. Maybe separate volume and actual sounds into separate modules or add an option to enable mic capture or something? It's not a trivial feature to add imo.


if (_audioDevice == null)
{
_logger.Verbose("No audio device found with Console role. Audio peak volume won't be updated.");
return false;
}

_audioEndpointVolume = _audioDevice.AudioEndpointVolume;

_audioEndpointVolume.OnVolumeNotification += _audioEndpointVolume_OnVolumeNotification;
DataModel.DefaultDeviceName = _audioDevice.FriendlyName;

_logger.Verbose($"AudioEndpoint device {_audioDevice.FriendlyName} registered to to fill AudioEndpoint volume data model");
return true;
}

private void _audioEndpointVolume_OnVolumeNotification(AudioVolumeNotificationData data)
{
UpdateVolumeDataModel();
}

#endregion
}
}
Loading