2

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();
        }
    }
}
Andreasvkn
  • 23
  • 3
  • 3
    Everytime your timer ticks, you create a new "AudioPlayer" instance and then player it. This could well be slow. I would suggest that you create the instance just once, store it in your class, and then just call "audioPlayer.Play()" in your timer. – jason.kaisersmith Apr 21 '23 at 08:35
  • That was my initial thought too, but I cannot seem to create it in the class, as it is an async? Can I make the partial class async somehow? – Andreasvkn Apr 21 '23 at 08:41
  • Why you can not save it? You have almost everething for it. Just add a condition to "Play()". Check if your "audioPlayer" field is not null. And if it's not -> play sound without creation of new instance – Ivan Kozlov Apr 21 '23 at 08:47
  • And remove "var" from Play() – Ivan Kozlov Apr 21 '23 at 08:49
  • Which Play() would I add the condition to? The async void? And the condition? Would that be an if statement to check if it is null? – Andreasvkn Apr 21 '23 at 09:01
  • 2
    I would consider creating am audio sample for the whole period, i.e. create a new stream of the click + some length of silence. And use `PlayLooping` to play this repeatedly. I would hope that should use the underlying hardware to ensure samples are looped exactly, but I have not played around with audio. – JonasH Apr 21 '23 at 09:17

2 Answers2

2

It is likely that the creation of a new instance each time is slowing down the process and causing your problems.

So instead try the following

Declare a class variable and set it to null.

private AudioPlayer audioPlayer = null;

Then check it in your play method, and set it if required

 public async void Play()
 {
        if (this.audioPlayer == null)
            this.audioPlayer = audioManager.CreatePlayer(await FileSystem.OpenAppPackageFileAsync("Perc_Can_hi.wav"));
        
        audioPlayer.Play();
 }

BTW: Async void is considered bad these days, so you should try to avoid this!

jason.kaisersmith
  • 8,712
  • 3
  • 29
  • 51
  • Thank you very much for the help! As I said to Ivan, the solution works and I've learned how to separate the two lines of code But the metronome is still just a random blurt of sounds - seems to be a bit more advanced to create a metronome than first anticipated – Andreasvkn Apr 21 '23 at 09:40
1

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

Ivan Kozlov
  • 255
  • 3
  • 13
  • Thank you very much for the answer! It works now - in the way that instance of a new player and the .Play() is separated and plays. However the metronome is still out of time, and I'm clueless now:) – Andreasvkn Apr 21 '23 at 09:39
  • You can't expect a timer to be precise. The timespan you specify is in reality the minimum amount of time guaranteed since the last tick. The actual amount of time can be more, and over time is can add up. – jason.kaisersmith Apr 21 '23 at 10:34
  • Thank you for the extremely detailed response, Ivan! I am still trying to comprehend it and implement it, but I will let you know if I run into any issues:) – Andreasvkn Apr 26 '23 at 09:06