I'm building a metronome as part of my practice app in Maui. I am using Plugin.maui.audio to play the sounds, and I'm using System.timer to determine the interval at which the sounds should be played. However the sounds are played at in irregular tempo and not in sync with whatever I set the timer.interval to be. I'm a big noob to this, so there is probably an easy explaination for?
I've tried separating creating the audioplayer itself and actually playing it, as the metronome shouldn't create a whole new player and load it for each time the metronome ticks, but I can't seem to get away with splitting up the two lines of code
var audioPlayer = audioManager.CreatePlayer(await FileSystem.OpenAppPackageFileAsync("Perc_Can_hi.wav"));
audioPlayer.Play();
Here is the XAML code:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="The_Jazz_App.MetronomePage1"
Title="Metronome"
BackgroundColor="DarkOrange">
<VerticalStackLayout Padding="100" Spacing="25">
<Label
Text="Slide to adjust bpm"
TextColor="Black"
VerticalOptions="Center"
HorizontalOptions="Center"/>
<Label
x:Name="bpmValue"
TextColor="Black"
VerticalOptions="Center"
HorizontalOptions="Center"/>
<Slider HorizontalOptions="Fill"
Maximum="400" Minimum="30"
ValueChanged="slider_ValueChanged"
x:Name="slider"/>
<ImageButton
Source="playbutton.png"
Pressed="ImageButton_Pressed"
VerticalOptions="Center"
HeightRequest="50"
WidthRequest="50"/>
<Picker VerticalOptions="Center" HorizontalOptions="Center" Title="Pick metronome sound" TitleColor="Black" TextColor="Black"/>
<Label x:Name="timerIntervalXAML"/>
</VerticalStackLayout>
</ContentPage>
And here is my xaml.cs code:
using System;
using System.Timers;
using System.Threading.Tasks;
using Plugin.Maui.Audio;
namespace The_Jazz_App;
public partial class MetronomePage1 : ContentPage
{
readonly System.Timers.Timer timer;
private readonly IAudioManager audioManager;
//default interval
double timerInterval = 3333;
public MetronomePage1(IAudioManager audioManager)
{
InitializeComponent();
this.audioManager = audioManager;
slider.Value = 200;
timer = new System.Timers.Timer();
timer.Interval = timerInterval;
timer.Elapsed += Timer_Elapsed;
timer.AutoReset = true;
timer.Enabled = false;
}
//The audioplayer itself
private IAudioPlayer audioPlayer;
public async void Play()
{
var audioPlayer = audioManager.CreatePlayer(await FileSystem.OpenAppPackageFileAsync("Perc_Can_hi.wav"));
audioPlayer.Play();
}
//Is supposed to play the sound repeatedly at the given BPM
public void Timer_Elapsed(object sender, ElapsedEventArgs e)
{
Play();
}
//A slider that lets the user choose the BPM of the metronome
public void slider_ValueChanged(object sender, ValueChangedEventArgs e)
{
double value = slider.Value;
bpmValue.Text = $"{((int)value)} bpm";
timerInterval = value / 60 * 1000;
timerIntervalXAML.Text = timerInterval.ToString();
}
//The button which activates the metronome
public void ImageButton_Pressed(object sender, EventArgs e)
{
if (timer.Enabled == false)
{
timer.Start();
}
else
{
timer.Stop();
}
}
}
I removed my previous answer, because Jason's answer is completely the same. But I wrote sample MAUI app to demostrate another way to reach your goal. I suggest you to use MVVM pattern in your MAUI apps because with it you can fill all power of XAML. I used CommunityToolkit.MAUI and CommunityToolkit.MVVM to reduce code. But you may not to use them (MVVM at least, because MAUI toolkit is very usefull).
So that's my idea.
MainPage.xaml:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Methronome"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:DataType="local:MainWindowViewModel"
x:Class="Methronome.MainPage">
<ContentPage.Behaviors>
<!--<toolkit:EventToCommandBehavior Command="{Binding NavigatedFromCommand}" EventName="NavigatedFrom" />-->
<toolkit:EventToCommandBehavior Command="{Binding LoadCommand}" EventName="Loaded" />
</ContentPage.Behaviors>
<ScrollView>
<VerticalStackLayout
Spacing="25"
Padding="30,0"
VerticalOptions="Center">
<Image
Source="dotnet_bot.png"
SemanticProperties.Description="Cute dot net bot waving hi to you!"
HeightRequest="200"
HorizontalOptions="Center" />
<Label
Text="Hello, World!"
SemanticProperties.HeadingLevel="Level1"
FontSize="32"
HorizontalOptions="Center" />
<Label
Text="Welcome to .NET Multi-platform App UI"
SemanticProperties.HeadingLevel="Level2"
SemanticProperties.Description="Welcome to dot net Multi platform App U I"
FontSize="18"
HorizontalOptions="Center" />
<Label Text="Timer interval" FontSize="18"
HorizontalOptions="Center"/>
<Label Text="{Binding TimerInterval}" FontSize="18"
HorizontalOptions="Center"/>
<Label Text="Slider value" FontSize="18"
HorizontalOptions="Center"/>
<Label Text="{Binding SliderValue}" FontSize="18"
HorizontalOptions="Center"/>
<Slider Maximum="400" Minimum="30" Value="{Binding SliderValue}"/>
<Button
x:Name="CounterBtn"
Text="Click me to start and stop"
Command="{Binding RunMethroCommand}"
SemanticProperties.Hint="Counts the number of times you click"
HorizontalOptions="Center" />
</VerticalStackLayout>
</ScrollView>
Not so much changes from your UI.
MainPage.xaml.cs
using Plugin.Maui.Audio;
namespace Methronome;
public partial class MainPage : ContentPage
{
int count = 0;
public MainPage(IAudioManager audioManager)
{
if (audioManager is null)
{
throw new ArgumentNullException(nameof(audioManager));
}
InitializeComponent();
//this place is one of the best to set ViewModel for any View
BindingContext = new MainWindowViewModel(audioManager);
}
}
MainWindowViewModel.cs:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Plugin.Maui.Audio;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Methronome
{
public partial class MainWindowViewModel : ObservableObject
{
#region Members
private readonly IAudioManager m_audioManager;
private IAudioPlayer m_audioPlayer;
private CancellationTokenSource m_cancellationTokenSource;
#endregion
#region Constructor
public MainWindowViewModel(IAudioManager audioManager)
{
m_audioManager = audioManager ?? throw new ArgumentNullException(nameof(audioManager));
m_cancellationTokenSource = new CancellationTokenSource();
}
#endregion
#region Observable properties
/// <summary>
/// This is property where value of slider is stored.
/// When you move slider this value will be changed automatically.
/// And if you change property made from this field (SliderValue) then slider will move
/// Also because of NotifyPropertyChangedFor(nameof(TimerInterval)), when this property changed - UI also notified about changes in TimeInterval property
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TimerInterval))]
int sliderValue = 60;
/// <summary>
/// Just switcher which desides tick or not
/// </summary>
[ObservableProperty]
bool isRunning = false;
#endregion
#region Properties
/// <summary>
/// Period of ticking in ms
/// </summary>
public int TimerInterval { get; set; } = 1000;
#endregion
#region Commads
/// <summary>
/// This command executed instantly from MainPage loaded.
/// </summary>
/// <returns></returns>
[RelayCommand]
async Task LoadAsync()
{
//init player
m_audioPlayer = m_audioManager.CreatePlayer(await FileSystem.OpenAppPackageFileAsync("tickSound.mp3"));
//run infinite asynchronies task where we decide to tick or not to tick
await Task.Factory.StartNew(async () =>
{
try
{
while (true)
{
//it will help us to get out of this loop
m_cancellationTokenSource.Token.ThrowIfCancellationRequested();
//here we make some noise if needed
if (IsRunning && TimerInterval > 0)
{
if (m_audioPlayer.IsPlaying)
{
m_audioPlayer.Stop();
}
m_audioPlayer.Play();
}
// this is delay for time interval
//working thread will wait until TimeInterval pass then continue
await Task.Delay(TimerInterval, m_cancellationTokenSource.Token);
}
}
catch (OperationCanceledException)
{
//get out from this loop
m_cancellationTokenSource = new CancellationTokenSource();
return;
}
catch (Exception ex)
{
// do whatever you want with this exception
// it means that smth went wrong
throw ex;
}
});
}
/// <summary>
/// This Command NOT used in current app.
/// But it may be needed for more complicated apps when you need to stop ticking on another pages
/// </summary>
/// <returns></returns>
[RelayCommand]
async Task NavigatedFrom()
{
//this will throw OperationCancelledException in infinite loop
m_cancellationTokenSource.Cancel();
await Task.Delay(400);
if (m_audioPlayer != null)
{
//author of this plugin suggest to dispose player after using
m_audioPlayer.Dispose();
}
}
/// <summary>
/// This command will be executed after clicking on button
/// Just switch between tick and silence
/// </summary>
[RelayCommand]
void RunMethro()
{
IsRunning = !IsRunning;
}
#endregion
#region Methods
/// <summary>
/// This method comes from auto-generated code.
/// This generation is made by CommunityToolkit.MVVM I strongly suggest to use it
/// </summary>
/// <param name="value"></param>
partial void OnSliderValueChanged(int value)
{
//change Time interval when slider moved
TimerInterval = (int)(value / 60.0 * 1000);
}
#endregion
}
}
I've tried to comment everything, but you can ask in case of some questions