Creating an extended metronome in WPF (problems with generated animation code and completed event)

Good day,

over the past few weeks, I have been working on a project to create an advanced metronome. The metronome consists of the following things:

  • swivel console
  • light flash
  • a set of dynamically created user controls that represent bits (4 of them that are either on, accented, or off).
  • user control that displays a liquid crystal digital display and calculates the number of milliseconds between bits for the selected BPM (60000 / BPM = milliseconds)

the user selects BPM and presses the start, and the following happens:

  • the lever swings between two angles at a speed of n milliseconds per scan
  • light flashes at the end of each sweep of the hand
  • indicators are created and they flash sequentially (one at the end of each sweep).

Now the problem is the animation "Hand" and "Light flash" are created in the code and added to the panel with repetition forever and are automatically reversed.

indicators are created in the code and should trigger an event at the end of each sweep animation.

So, what I did after multithreading created a timer that works at the same pace as the storyboard.

the problem, for more than 30 seconds, the timer and storyboard fail, so the indicators and the sweep of the hand do not have time (not good for the metronome !!).

I tried to catch the completed animation event and use it as a trigger to stop and restart the timer, that was all I could think of to keep two in perfect sync.

turning off the synchronization is due to the sliding of the storyboard and the fact that the storyboard is called from the beginning on the line before the timer is called using .start, although microseconds, I think, they start incredibly close, but not exactly in the same time.

my question is, when I try to bind to a completed animation event, it never fires. I got the impression that he even completed the fires regardless of the auto-reverse (that is, between each iteration). this is not true?

can anyone think of another (trickier) way to synchronize two things.

Finally, I really looked if I could start the method from the storyboard (which would make my life very easy, but it would turn out that this could not be done).

If there are any suggestions that I don’t appreciate, I just want it to end !!

the end point of interest, bpm can be adjusted while the metronome is running, this is achieved by calculating the millisecond duration on the fly (clicking the mouse button) and scaling the storyboard to the difference between the current speed and the new speed. it is obvious that the timer that starts the indicators must be changed at the same time (using the interval).

below is the code of my project (not XAML only C #)

using System; using System.Collections.Generic; using System.Windows; using System.Windows.Input; using System.Windows.Media.Animation; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Controls; using System.Windows.Threading; namespace MetronomeLibrary { public partial class MetronomeLarge { private bool Running; //Speed and time signature private int _bpm = 60; private int _beats = 4; private int _beatUnit = 4; private int _currentBeat = 1; private readonly int _baseSpeed = 60000 / 60; private readonly DispatcherTimer BeatTimer = new DispatcherTimer(); private Storyboard storyboard = new Storyboard(); public MetronomeLarge() { InitializeComponent(); NumericDisplay.Value = BPM; BeatTimer.Tick += new EventHandler(TimerTick); SetUpAnimation(); SetUpIndicators(); } public int Beats { get { return _beats; } set { _beats = value; SetUpIndicators(); } } public int BPM { get { return _bpm; } set { _bpm = value; //Scale the story board here SetSpeedRatio(); } } public int BeatUnit { get { return _beatUnit; } set { _beatUnit = value; } } private void SetSpeedRatio() { //divide the new speed (bpm by the old speed to get the new ratio) float newMilliseconds = (60000 / BPM); float newRatio = _baseSpeed / newMilliseconds; storyboard.SetSpeedRatio(newRatio); //Set the beat timer according to the beattype (standard is quarter beats for one sweep of the metronome BeatTimer.Interval = TimeSpan.FromMilliseconds(newMilliseconds); } private void TimerTick(object sender, EventArgs e) { MetronomeBeat(_currentBeat); _currentBeat++; if (_currentBeat > Beats) { _currentBeat = 1; } } private void MetronomeBeat(int Beat) { //turnoff all indicators TurnOffAllIndicators(); //Find a control by name MetronomeLargeIndicator theIndicator = (MetronomeLargeIndicator)gridContainer.Children[Beat-1]; //illuminate the control theIndicator.TurnOn(); theIndicator.PlaySound(); } private void TurnOffAllIndicators() { for (int i = 0; i <= gridContainer.Children.Count-1; i++) { MetronomeLargeIndicator theIndicator = (MetronomeLargeIndicator)gridContainer.Children[i]; theIndicator.TurnOff(); } } private void SetUpIndicators() { gridContainer.Children.Clear(); gridContainer.ColumnDefinitions.Clear(); for (int i = 1; i <= _beats; i++) { MetronomeLargeIndicator theNewIndicator = new MetronomeLargeIndicator(); ColumnDefinition newCol = new ColumnDefinition() { Width = GridLength.Auto }; gridContainer.ColumnDefinitions.Add(newCol); gridContainer.Children.Add(theNewIndicator); theNewIndicator.Name = "Indicator" + i.ToString(); Grid.SetColumn(theNewIndicator, i - 1); } } private void DisplayOverlay_MouseDown(object sender, MouseButtonEventArgs e) { ToggleAnimation(); } private void ToggleAnimation() { if (Running) { //stop the animation ((Storyboard)Resources["Storyboard"]).Stop() ; BeatTimer.Stop(); } else { //start the animation BeatTimer.Start(); ((Storyboard)Resources["Storyboard"]).Begin(); SetSpeedRatio(); } Running = !Running; } private void ButtonIncrement_Click(object sender, RoutedEventArgs e) { NumericDisplay.Value++; BPM = NumericDisplay.Value; } private void ButtonDecrement_Click(object sender, RoutedEventArgs e) { NumericDisplay.Value--; BPM = NumericDisplay.Value; } private void ButtonIncrement_MouseEnter(object sender, MouseEventArgs e) { ImageBrush theBrush = new ImageBrush() { ImageSource = new BitmapImage(new Uri(@"pack://application:,,,/MetronomeLibrary;component/Images/pad-metronome-increment-button-over.png")) }; ButtonIncrement.Background = theBrush; } private void ButtonIncrement_MouseLeave(object sender, MouseEventArgs e) { ImageBrush theBrush = new ImageBrush() { ImageSource = new BitmapImage(new Uri(@"pack://application:,,,/MetronomeLibrary;component/Images/pad-metronome-increment-button.png")) }; ButtonIncrement.Background = theBrush; } private void ButtonDecrement_MouseEnter(object sender, MouseEventArgs e) { ImageBrush theBrush = new ImageBrush() { ImageSource = new BitmapImage(new Uri(@"pack://application:,,,/MetronomeLibrary;component/Images/pad-metronome-decrement-button-over.png")) }; ButtonDecrement.Background = theBrush; } private void ButtonDecrement_MouseLeave(object sender, MouseEventArgs e) { ImageBrush theBrush = new ImageBrush() { ImageSource = new BitmapImage(new Uri(@"pack://application:,,,/MetronomeLibrary;component/Images/pad-metronome-decrement-button.png")) }; ButtonDecrement.Background = theBrush; } private void SweepComplete(object sender, EventArgs e) { BeatTimer.Stop(); BeatTimer.Start(); } private void SetUpAnimation() { NameScope.SetNameScope(this, new NameScope()); RegisterName(Arm.Name, Arm); DoubleAnimation animationRotation = new DoubleAnimation() { From = -17, To = 17, Duration = new Duration(TimeSpan.FromMilliseconds(NumericDisplay.Milliseconds)), RepeatBehavior = RepeatBehavior.Forever, AccelerationRatio = 0.3, DecelerationRatio = 0.3, AutoReverse = true, }; Timeline.SetDesiredFrameRate(animationRotation, 90); MetronomeFlash.Opacity = 0; DoubleAnimation opacityAnimation = new DoubleAnimation() { From = 1.0, To = 0.0, AccelerationRatio = 1, BeginTime = TimeSpan.FromMilliseconds(NumericDisplay.Milliseconds - 0.5), Duration = new Duration(TimeSpan.FromMilliseconds(100)), }; Timeline.SetDesiredFrameRate(opacityAnimation, 10); storyboard.Duration = new Duration(TimeSpan.FromMilliseconds(NumericDisplay.Milliseconds * 2)); storyboard.RepeatBehavior = RepeatBehavior.Forever; Storyboard.SetTarget(animationRotation, Arm); Storyboard.SetTargetProperty(animationRotation, new PropertyPath("(UIElement.RenderTransform).(RotateTransform.Angle)")); Storyboard.SetTarget(opacityAnimation, MetronomeFlash); Storyboard.SetTargetProperty(opacityAnimation, new PropertyPath("Opacity")); storyboard.Children.Add(animationRotation); storyboard.Children.Add(opacityAnimation); Resources.Add("Storyboard", storyboard); } } } 
+7
source share
4 answers

This can be difficult to implement with WPF animations. Instead, a game loop is a good method. A little research should bring a lot of resources to this. The first that jumped at me was http://www.nuclex.org/articles/3-basics/5-how-a-game-loop-works .

In the game loop, you will follow one or the other of these basic procedures:

  • Calculate how much time has passed since the last frame.
  • Move the displays accordingly.

or

  • Calculate current time.
  • Arrange your displays accordingly.

The advantage of the game loop is that although the time may deviate slightly (depending on what type of time you use), all displays will drift by the same amount.

You can prevent clock drift by calculating the time with a system clock that for practical purposes does not drift. Drift timers because they do not work on the system clock.

+2
source

Time synchronization is a wider field than you think.

I suggest you take a look at Quartz.NET , which is known as scheduling / timer issues.

Syncing WPF animations is difficult because storyboards are not part of the logical tree, so you cannot link them.
This is why you cannot define dynamic / variable storyboards in XAML, you have to do it in C #, just like you.

I suggest you make 2 storyboards: one for the mark on the left and the other on the right.
Between each animation, run a method to perform your calculations / update another part of the user interface, but do it in a separate Task , so that the timings are not corrupted (a few microseconds for calculations take a lot of time after 30 already!)
Keep in mind that you will need to use Application.Current.Dispatcher from Task to update the user interface.

Finally, at least set the Task TaskCreationOptions.PreferFairness flag so that the tasks run in the order in which they were started.
Now, since this just gives the TaskScheduler hint and does not guarantee their execution in order, you can use the queuing system instead of a full guarantee.

NTN

Women.

+1
source

You can try 2 animations, one for the right swing and one for the left. In each animation, start another animation (checking the cancel flags) and update your indicators (perhaps through BeginInvoke on the Manager so that you do not interfere with the start of the next animation).

0
source

I think it’s difficult to make the timer synchronize with the animation - this is a dispatcher-based timer that is based on messages - sometimes it may miss a little time, that is, if you quickly click on the mouse, I think the animation timer is also based on dispatchers, therefore they will easily come out of sync.

I would suggest abandoning synchronization and letting the timer handle it. Can you let it update the property with a notification and let your metronome position snap to it? To get acceleration / deceleration, you just need to use the sine or cosine function.

0
source

All Articles