TL/DR: you can use both Task.Delay and SpinLock.SpinOnce() + StopWatch in order to delay by a precise amount while still allowing for a performant UI.
Recently I tried to use the C# Task.Delay() in order to provide the delay in playing MIDI piano notes from old piano rolls that had been scanned in. (Just playing the MIDI files didn’t meet my requirements for the project; I also wanted to animate the notes on a musical score and to highlight the notes on a virtual piano keyboard). The results were disappointing: the resulting songs were musical, but with the note timings horribly off.
A quick investigation using the C# Diagnostics.StopWatch object showed why: the Task.Delay() was being asked to delay (in many cases) for 5 to 20 milliseconds. MIDI are presented as a series of NOTE ON and NOTE OFF events; each includes a number of ticks to wait before performing the action. In one typical MIDI file from a piano roll, there are 3778 notes with a median time to wait of 18.75 milliseconds; the Q1 point is 3.125 milliseconds and the Q3 at 62.5 milliseconds
Each call to Task.Delay() is not precise and often ends after the requested completion time. This was called the “time overage” for the call. The median time overage was 9.855 milliseconds with a Q1 of 5.3 milliseconds and Q3 of 12.34 milliseconds. These overages were killing the musicality of the performance.
The first solution was to use a System.Threading.SpinWait lock for the correct number of milliseconds. This worked as far as the music went, but the spin lock prevented the screen from being updated.
The second solution was to do a Task.Delay() for each note except for the last 20 milliseconds of each note. Each piano roll, as encoded by the MIDI files, includes plenty of longer requested time delays so that there are still plenty of Task.Delay() calls. These calls enable the screen to stay refreshed.
The final data is quite pleasing. The median time overage dropped from 18.75 milliseconds to a radically smaller 0.0188 milliseconds.