melanchall / drywetmidi

.NET library to read, write, process MIDI files and to work with MIDI devices
https://melanchall.github.io/drywetmidi
MIT License
545 stars 75 forks source link

Library uses obsolete Windows function #106

Open mklemarczyk opened 3 years ago

mklemarczyk commented 3 years ago

In the file: https://github.com/melanchall/drywetmidi/blob/71bc4cabe87e8c69cba90a4f2211753cfe838118/DryWetMidi/Devices/Clock/TickGenerator/MidiTimerWinApi.cs

Library uses obsolete Windows function:

public static extern uint timeSetEvent(uint uDelay, uint uResolution, TimeProc lpTimeProc, IntPtr dwUser, uint fuEvent);

https://docs.microsoft.com/en-us/previous-versions/dd757634(v=vs.85) https://docs.microsoft.com/en-us/windows/win32/multimedia/starting-a-single-timer

It would be recommended to replace method with new equivalent before method get deprecated and removed from future versions of Windows. As Windows 10 is a subject to obligatory auto-update, all Windows 10 users will be affected in the future.

More informations on the available Times in C#: https://docs.microsoft.com/en-us/dotnet/api/system.threading.timer?view=net-5.0#remarks

melanchall commented 3 years ago

Hi,

Thanks for information. But DryWetMIDI uses setTimeEvent intentionally. Although it's obsolete, it provides the highest resolution among all timer APIs. There are number of threads on the web regarding this subject. For example:

The last article shows that setTimeEvent is the most accurate timer API on Windows.

More informations on the available Times in C#: https://docs.microsoft.com/en-us/dotnet/api/system.threading.timer?view=net-5.0#remarks

All these timers have super poor accuracy. You can find a lot of discussions on the web on this subject. If shortly, it's impossible to get resolution less than 16 ms with these timers. But playback API needs resolution of 1 ms to achieve the lowest possible latency.

Also note that you can use theses timers with DryWetMIDI. There is RegularPrecisionTickGenerator that uses System.Timers.Timer. Or you can build custom tick generator that will use any timer API you want.

But right now I don't see a strong reason to get rid of setTimeEvent.

mklemarczyk commented 3 years ago

I understand your point. I also looked into this issue before. The subject is very poorly documented. Moreover the standard times of C# has 1ms resolution under Linux systems. In addition to it, the demo application to verify the resolution of system shows that 0.1 ms resolution can be achieved, but the method (timeBeginPeriod) used by Multimedia Timers accept only integer number.

I do not say that the function need to be replaced ASAP, but that it is good to take a look on the subject. Most of the articles that say the Multimedia Times are the only way are years old, the CPU has far more resolution since than.

The other question is which method is used to provide low latency on high FPS applications, as you mentioned the resolution 15 ms can not support higher than 60 FPS usage.

melanchall commented 3 years ago

Hmm, thanks for the info. Very interesting. I'll do some tests and let you know what I get. Thanks again!

melanchall commented 3 years ago

I found very useful document from Microsoft: Timer-Resolution.docx. I'll try to implement recommendations from there and rework how DWM uses high resolution for internal timer.

Thanks again for the issue!

melanchall commented 3 years ago

Unfortunately it seems there is a bug in Windows 10 which leads to result of timeBeginPeriod is ignored, so there is no way to get high precision other than use timeSetEvent :(

I'll keep the issue open to remember about the problem.

melanchall commented 3 years ago

I've done some tests on Windows, Ubuntu and macOS (virtual machines) to see how System.Timers.Timer keeps specified interval.

Test program:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Timer = System.Timers.Timer;

namespace TimerTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var interval = int.Parse(args[0]);

            var stopwatch = new Stopwatch();
            var times = new List<long>();

            var timer = new Timer(interval);
            timer.Elapsed += (_, _) => times.Add(stopwatch.ElapsedMilliseconds);

            stopwatch.Start();
            timer.Start();

            //

            SpinWait.SpinUntil(() => times.Count >= 1000);
            timer.Stop();
            stopwatch.Stop();

            //

            var time = times[0];
            var deltas = new List<long>();

            for (var i = 1; i < times.Count; i++)
            {
                var delta = times[i] - time;
                deltas.Add(delta);
                time = times[i];
            }

            //

            Console.WriteLine($"Statistics for {times.Count} measures by interval of {interval}ms:");
            Console.WriteLine();
            Console.WriteLine($"min = {deltas.Min()}");
            Console.WriteLine($"max = {deltas.Max()}");
            Console.WriteLine($"avg = {deltas.Average()}");
        }
    }
}

Results are:

Windows

Interval (ms) Min (ms) Max (ms) Average (ms)
1 4 26 15.6
2 2 29 15.6
10 1 30 15.6
100 77 126 99.9

Ubuntu

Interval (ms) Min (ms) Max (ms) Average (ms)
1 0 10 4.0
2 1 8 4.0
10 3 14 9.9
100 92 105 99.9

macOS

Interval (ms) Min (ms) Max (ms) Average (ms)
1 0 25 2.4
2 1 33 3.2
10 1 90 15.9
100 0 248 103.8

So in fact only Ubuntu have appropriate accuracy for Timer. Other operating systems have super big maximum values.

operatortwo commented 3 years ago

Sorry, I am not an advanced programmer, and I don't know if I understand it correctly, but at Microsoft docs at 'timeSetEvent function' :

Note This function is obsolete. New applications should use CreateTimerQueueTimer to create a timer-queue timer.

CreateTimerQueueTimer function

I also used TimeSetEvent before, then I tried to use CreateTimerQueueTimer in my midi-player and it seems to work (hobby-level) It was important to set flags to WT_EXECUTEINTIMERTHREAD.

Sorry for non C#, but I thought it is better to post the working original code, than an untested translation

`  
 Declare Auto Function CreateTimerQueueTimer Lib "kernel32.dll" (ByRef phNewTimer As IntPtr, hTimerQueue As UInteger, lpTimeProc As TimerProc, cbParam As UInteger, DueTime As UInteger, Period As UInteger, Flags As UInteger) As Boolean

 Declare Auto Function DeleteTimerQueueTimer Lib "kernel32.dll" (hTimerQueue As UInteger, hTimer As UInteger, CompletionEvent As Integer) As Boolean

    Delegate Sub TimerProc(lpParameter As IntPtr, TimerOrWaitFired As Boolean)
    Private ReadOnly fptrTimerProc As New TimerProc(AddressOf TickCallback)
Private PlayerClock_Handle As IntPtr

Private Sub StartPlayerTick()
        Dim tDuetime As UInteger = 1                  ' ms to first event
        Dim tPeriod As UInteger = 1                   ' when > 0 : Repetition time in ms
        Dim ret As Boolean                      ' TRUE (NonZero) if ok, FALSE if failed

        Const WT_EXECUTEINTIMERTHREAD As UInteger = &H20

        ret = CreateTimerQueueTimer(PlayerClock_Handle, 0, fptrTimerProc, 0, tDuetime, tPeriod, WT_EXECUTEINTIMERTHREAD)

        'Flags=&H20 WT_EXECUTEINTIMERTHREAD  The callback function is invoked by the timer thread itself
        ' otherwise the midi playback will not work properly (note hang-ups, etc.)        
    End Sub

    Private Sub StopPlayerTick()
        Dim ret As Boolean                      ' TRUE (NonZero) if ok, FALSE if failed
        ret = DeleteTimerQueueTimer(0, CUInt(PlayerClock_Handle), 0)
        PlayerClock_Handle = IntPtr.Zero                ' Handle is no longer valid -> set to 0
    End Sub

Public Sub TickCallback(lpParameter As IntPtr, TimerOrWaitFired As Boolean)
' ...
End Sub

By the way, I saw this thread by chance. I just want to make some tests with this great library to see how I can use it with my (hobby) application.

melanchall commented 3 years ago

@operatortwo Thanks for the info! I'll try it.

melanchall commented 2 years ago

@operatortwo I've done a quick test and unfortunately I see 15.6 ms min interval, even if I set 1 ms. It's standard timer resolution for Windows and there is a bug on Windows 10 which makes impossible to use lower intervals with any methods except old timeSetEvent.

Unfortunately it seems timeSetEvent is the only reliable way to get 1 ms timer.

operatortwo commented 2 years ago

I agree that timeSetEvent is the only reliable way to get 1 ms timer.

Just made a few tests to find out why I got good measurement results (down to 1 ms) with CreateTimerQueueTimer . It seems that the Midi-Driver had an effetct to the timer. When the Midi-Port was open, then the measured intervals were around 1 ms, after closing the port, the intervals were back to 15.6 ms. Same results with a 4x4 Midi In/Out Box. Changed from Desktop PC to Notebook using another 4x4 Midi In/Out Box, same results. Changed to another Notebook with Windows 11, same results.

But there was no effect with the software 'GS wavetable Synth' and also not with class-compliant drivers installed by plug-and-play.

melanchall commented 2 years ago

@operatortwo What is Midi-Driver and Midi-Port? Do you mean plugging hardware devices allows set timer interval down to 1 ms?

operatortwo commented 2 years ago

What is Midi-Driver and Midi-Port?

A Midi-Driver is sometimes provided by the manufacturer of a Midi-Hardware for the use of device-specific applications or "to ensure optimal performance". This drivers are normally downloaded an installed by the user. For example: Yamaha USB-MIDI Driver V3.1.4 for Win 10/8.1/8/7 (64-bit) M-Audio MIDISPORT Windows Driver v6.1.3 (for MIDISPORT 4x4 and MIDISPORT 4x4 Anniversary Edition)

Do you mean plugging hardware devices allows set timer interval down to 1 ms?

No. It seems to be more a special function in some, but not all USB-MIDI drivers. After an application causes the driver to connect to the device, a TimerQueueTimer can be created with an interval of 1 ms (measured), after the connection is closed, a new Timer can not anymore be set to a lower interval than 15.6 ms.

I used a litte application that creates a timer, does a loop count 100 and shows the elapsed milliseconds between all timerticks in a list. Beside this, I used a second application to connect a Midi-device. It didn't matter which application I used, the change (in the measuring app) always occurred when I selected / deselected a device with one of the drivers mentioned above. (in the second app) I used a MidiLibrarian, a Midi monitor tool, a Midi monitor store app , a free sequencer... Usually the Midi-device can be selected in the 'Setup' menu. I also made a short test with DWM, I got fast results between 'Playback.Start' and 'OutputDevice.Dispose' (always in the case I selected a device with one of the corresponding drivers above)

melanchall commented 2 years ago

Yes, I know what a device driver is. I thought you mean some special devices and software.

Hmm, very interesting. Maybe driver does some OS level manipulations internally. Thanks for the info!