Using winmm.dll to generate MIDI audio

  • Thread starter Thread starter Reed Kimble
  • Start date Start date
R

Reed Kimble

Guest
The topic of working with MIDI files from VB.Net comes up from time to time and generally, if you just want to play a MIDI file, the easiest solution is to use an instance of MediaPlayer which can play MIDI files.

But if you want to actually control the MIDI device and play music note-by-note, then things get a little more involved. The winmm.dll offers an API over MIDI devices, but requires unmanaged code to utilize. There are a number of wrappers available but they tend to be cumbersome and/or bloated for simple audio playback. The winmm.dll provides full functionality for both MIDI Input and MIDI Output devices, but much of this functionality is unneeded when the only goal is to generate some MIDI music from code.

To resolve this issue and provide the basic foundation for MIDI interfacing with VB.Net, the following code will introduce the SimpleMidiEngine API for generating MIDI music via code, or from simple strings of note information.

A CodeProject Article was used as reference for the winmm implementation, along with the Microsoft reference documentation. Instrument names were taken from this site, while general MIDI message information was found here.

The first class in the project is the NativeMethods class which wraps the winmm API calls and provides the required managed types:

Option Strict On
Imports System.Runtime.InteropServices

'translated in part from: https://www.codeproject.com/articles/6228/c-midi-toolkit
Public NotInheritable Class NativeMethods
Public Delegate Sub MidiOutProc(handle As IntPtr, msg As Integer, instance As Integer,
param1 As Integer, param2 As Integer)

Protected Declare Auto Function midiOutOpen Lib "winmm.dll" (ByRef handle As IntPtr, deviceId As Integer, proc As MidiOutProc, instance As Integer, flags As Integer) As Integer
Protected Declare Auto Function midiOutClose Lib "winmm.dll" (handle As IntPtr) As Integer
Protected Declare Auto Function midiOutReset Lib "winmm.dll" (handle As IntPtr) As Integer
Protected Declare Auto Function midiOutShortMsg Lib "winmm.dll" (handle As IntPtr, message As Integer) As Integer
Protected Declare Auto Function midiOutGetDevCaps Lib "winmm.dll" (handle As IntPtr, ByRef caps As MidiOutCaps, sizeOfmidiOutCaps As Integer) As Integer
Protected Declare Auto Function midiOutGetNumDevs Lib "winmm.dll" () As Integer

Protected Const MMSYSERR_NOERROR As Integer = 0
Public Const CALLBACK_FUNCTION As Integer = &H30000

Public Shared ReadOnly Property MidiOutCapsSize As Integer = Marshal.SizeOf(Of MidiOutCaps)

Public Shared Sub OpenPlaybackDevice(ByRef handle As IntPtr, deviceId As Integer, proc As MidiOutProc, instance As Integer, flags As Integer)
If Not midiOutOpen(handle, deviceId, proc, instance, flags) = MMSYSERR_NOERROR Then Throw New Exception("Open failed")
End Sub

Public Shared Sub ClosePlaybackDevice(handle As IntPtr)
If Not midiOutClose(handle) = MMSYSERR_NOERROR Then Throw New Exception("Close failed")
End Sub

Public Shared Sub ResetPlaybackDevice(handle As IntPtr)
If Not midiOutReset(handle) = MMSYSERR_NOERROR Then Throw New Exception("Reset failed")
End Sub

Public Shared Sub SendPlaybackDeviceMessage(handle As IntPtr, message As Integer)
If Not midiOutShortMsg(handle, message) = MMSYSERR_NOERROR Then Throw New Exception("Send message failed")
End Sub

Public Shared Sub GetPlaybackDeviceCapabilities(handle As IntPtr, ByRef caps As MidiOutCaps, sizeOfmidiOutCaps As Integer)
If Not midiOutGetDevCaps(handle, caps, sizeOfmidiOutCaps) = MMSYSERR_NOERROR Then Throw New Exception("Get device capabilities failed")
End Sub

Public Shared Function GetPlaybackDeviceCount() As Integer
Dim result = midiOutGetNumDevs
If Not result > MMSYSERR_NOERROR Then Throw New Exception("Get device count failed - no devices present")
Return result
End Function

Protected Sub New()
End Sub

'translated from: https://www.codeproject.com/articles/6228/c-midi-toolkit
Public Structure MidiOutCaps
''' <summary>
''' Manufacturer identifier of the device driver for the Midi output
''' device.
''' </summary>
Public ManufacturerId As Short

''' <summary>
''' Product identifier of the Midi output device.
''' </summary>
Public ProductId As Short

''' <summary>
''' Version number of the device driver for the Midi output device. The
''' high-order byte Is the major version number, And the low-order byte
''' Is the minor version number.
''' </summary>
Public DriverVersion As Integer

''' <summary>
''' Product name string as ASCII byte array.
''' </summary>
<MarshalAs(UnmanagedType.ByValArray, SizeConst:=32)>
Public Name As Byte()

''' <summary>
''' Flags describing the type of the Midi output device.
''' </summary>
Public Technology As Short

''' <summary>
''' Number of voices supported by an internal synthesizer device. If
''' the device Is a port, this member Is Not meaningful And Is set
''' to 0.
''' </summary>
Public VoiceCount As Short

''' <summary>
''' Maximum number of simultaneous notes that can be played by an
''' internal synthesizer device. If the device Is a port, this member
''' Is Not meaningful And Is set to 0.
''' </summary>
Public NoteCount As Short

''' <summary>
''' Channels that an internal synthesizer device responds to, where the
''' least significant bit refers to channel 0 And the most significant
''' bit to channel 15. Port devices that transmit on all channels set
''' this member to 0xFFFF.
''' </summary>
Public ChannelMask As Short

''' <summary>
''' Optional functionality supported by the device.
''' </summary>
Public Support As Integer
End Structure
End Class



This is the primary code for working with winmm.dll and sending MIDI messages. You could play music with this code alone, but it will be easier to add some helper classes for sending messages and representing musical note data.

The MIDI messages can be wrapped in a simple 4-byte structure. Only three of the bytes are actually used to hold information, the fourth is simply to make an even 4 bytes for integer conversion.

<System.Runtime.InteropServices.StructLayout(Runtime.InteropServices.LayoutKind.Sequential)>
Public Structure MidiShortMessage
Private Const CHANNEL_MASK As Byte = &HF
Private Const STATUS_MASK As Byte = &HF0

Private data0 As Byte
Private data1 As Byte
Private data2 As Byte
Private data3 As Byte

Public Sub New(command As MessageCommandMask, midiChannel As Byte, value1 As Byte, value2 As Byte)
StatusCommand = command
Channel = midiChannel
Parameter1 = value1
Parameter2 = value2
End Sub

Public Property StatusCommand As MessageCommandMask
Get
Return CType(data0 >> 4, MessageCommandMask)
End Get
Set(value As MessageCommandMask)
data0 = value Or (data0 And CHANNEL_MASK)
End Set
End Property

Public Property Channel As Byte
Get
Return (data0 And CHANNEL_MASK)
End Get
Set(value As Byte)
data0 = (data0 And STATUS_MASK) Or (value And CHANNEL_MASK)
End Set
End Property

Public Property Parameter1 As Byte
Get
Return data1
End Get
Set(value As Byte)
data1 = value
End Set
End Property

Public Property Parameter2 As Byte
Get
Return data2
End Get
Set(value As Byte)
data2 = value
End Set
End Property

Public Shared Widening Operator CType(target As MidiShortMessage) As Integer
Return BitConverter.ToInt32({target.data0, target.data1, target.data2, target.data3}, 0)
End Operator

Public Enum MessageCommandMask As Byte
NoteOff = &H80
NoteOn = &H90
PolyKeyPressure = &HA0
ControllerChange = &HB0
ProgramChange = &HC0
ChannelPressure = &HD0
PitchBend = &HD0
End Enum
End Structure

This message structure is used to send command such as setting the voice for a channel or playing notes.

There will need to be a small class to encapsulate the MIDI playback device, holding its device id and information about its capabilities.

Public Class PlaybackDevice

Public Shared Function DefaultDevice() As PlaybackDevice
Dim caps As New NativeMethods.MidiOutCaps
NativeMethods.GetPlaybackDeviceCapabilities(0, caps, NativeMethods.MidiOutCapsSize)
Return New PlaybackDevice(0, caps)
End Function

Public Shared Function GetDevices() As IEnumerable(Of PlaybackDevice)
Dim result As New List(Of PlaybackDevice)
Dim deviceCount = NativeMethods.GetPlaybackDeviceCount
For i = 0 To deviceCount - 1
Dim caps As New NativeMethods.MidiOutCaps
NativeMethods.GetPlaybackDeviceCapabilities(i, caps, NativeMethods.MidiOutCapsSize)
result.Add(New PlaybackDevice(i, caps))
Next
Return result.ToArray
End Function

Public Property DeviceId As Integer
Public Property DeviceName As String
Public Property VoiceCount As Integer
Public Property ChannelEnabled As IEnumerable(Of Boolean)
Public Property NoteCount As Integer

Public Sub New(id As Integer, caps As NativeMethods.MidiOutCaps)
DeviceId = id
DeviceName = Text.ASCIIEncoding.ASCII.GetString(caps.name)
VoiceCount = caps.VoiceCount
Dim channels As New List(Of Boolean)
For i = 0 To 15
If (caps.ChannelMask And CShort(2 ^ i - 1)) > 0 Then
channels.Add(True)
Else
channels.Add(False)
End If
Next
NoteCount = caps.NoteCount
ChannelEnabled = channels
End Sub
End Class

With those classes and structures in place, we are almost ready to write a MidiPlayer. But before we do, we'll need some objects and enums to represent the notes that we want to play, along with the instrument that will play them.

The instrument (or Voice) can simply be represented by an enum of voice Ids. The following list represents the default MIDI voice bank:

Public Enum InstrumentVoice As Byte
Acoustic_Grand_Piano = 1
Bright_Acoustic_Piano = 2
Electric_Grand_Piano = 3
Honky_tonk_Piano = 4
Electric_Piano_1 = 5
Electric_Piano_2 = 6
Harpsichord = 7
Clavi = 8
Celesta = 9
Glockenspiel = 10
Music_Box = 11
Vibraphone = 12
Marimba = 13
Xylophone = 14
Tubular_Bells = 15
Dulcimer = 16
Drawbar_Organ = 17
Percussive_Organ = 18
Rock_Organ = 19
Church_Organ = 20
Reed_Organ = 21
Accordion = 22
Harmonica = 23
Tango_Accordion = 24
Acoustic_Guitar_nylon = 25
Acoustic_Guitar_steel = 26
Electric_Guitar_jazz = 27
Electric_Guitar_clean = 28
Electric_Guitar_muted = 29
Overdriven_Guitar = 30
Distortion_Guitar = 31
Guitar_harmonics = 32
Acoustic_Bass = 33
Electric_Bass_finger = 34
Electric_Bass_pick = 35
Fretless_Bass = 36
Slap_Bass_1 = 37
Slap_Bass_2 = 38
Synth_Bass_1 = 39
Synth_Bass_2 = 40
Violin = 41
Viola = 42
Cello = 43
Contrabass = 44
Tremolo_Strings = 45
Pizzicato_Strings = 46
Orchestral_Harp = 47
Timpani = 48
String_Ensemble_1 = 49
String_Ensemble_2 = 50
SynthStrings_1 = 51
SynthStrings_2 = 52
Choir_Aahs = 53
Voice_Oohs = 54
Synth_Voice = 55
Orchestra_Hit = 56
Trumpet = 57
Trombone = 58
Tuba = 59
Muted_Trumpet = 60
French_Horn = 61
Brass_Section = 62
SynthBrass_1 = 63
SynthBrass_2 = 64
Soprano_Sax = 65
Alto_Sax = 66
Tenor_Sax = 67
Baritone_Sax = 68
Oboe = 69
English_Horn = 70
Bassoon = 71
Clarinet = 72
Piccolo = 73
Flute = 74
Recorder = 75
Pan_Flute = 76
Blown_Bottle = 77
Shakuhachi = 78
Whistle = 79
Ocarina = 80
Lead_1_square = 81
Lead_2_sawtooth = 82
Lead_3_calliope = 83
Lead_4_chiff = 84
Lead_5_charang = 85
Lead_6_voice = 86
Lead_7_fifths = 87
Lead_8_bass_lead = 88
Pad_1_new_age = 89
Pad_2_warm = 90
Pad_3_polysynth = 91
Pad_4_choir = 92
Pad_5_bowed = 93
Pad_6_metallic = 94
Pad_7_halo = 95
Pad_8_sweep = 96
FX_1_rain = 97
FX_2_soundtrack = 98
FX_3_crystal = 99
FX_4_atmosphere = 100
FX_5_brightness = 101
FX_6_goblins = 102
FX_7_echoes = 103
FX_8_sci_fi = 104
Sitar = 105
Banjo = 106
Shamisen = 107
Koto = 108
Kalimba = 109
Bag_pipe = 110
Fiddle = 111
Shanai = 112
Tinkle_Bell = 113
Agogo = 114
Steel_Drums = 115
Woodblock = 116
Taiko_Drum = 117
Melodic_Tom = 118
Synth_Drum = 119
Reverse_Cymbal = 120
Guitar_Fret_Noise = 121
Breath_Noise = 122
Seashore = 123
Bird_Tweet = 124
Telephone_Ring = 125
Helicopter = 126
Applause = 127
Gunshot = 128
End Enum

Next we'll need a way to represent each of the chromatic notes across 8 octaves of a full piano keyboard. This can be done with another enum holding an encoded version of the note name along with the associated MIDI note code:

Public Enum NoteMidiCode As Byte
Rest = 0
C8 = 108
B7 = 107
A7s = 106
A7 = 105
G7s = 104
G7 = 103
F7s = 102
F7 = 101
E7 = 100
D7s = 99
D7 = 98
C7s = 97
C7 = 96
B6 = 95
A6s = 94
A6 = 93
G6s = 92
G6 = 91
F6s = 90
F6 = 89
E6 = 88
D6s = 87
D6 = 86
C6s = 85
C6 = 84
B5 = 83
A5s = 82
A5 = 81
G5s = 80
G5 = 79
F5s = 78
F5 = 77
E5 = 76
D5s = 75
D5 = 74
C5s = 73
C5 = 72
B4 = 71
A4s = 70
A4 = 69
G4s = 68
G4 = 67
F4s = 66
F4 = 65
E4 = 64
D4s = 63
D4 = 62
C4s = 61
C4 = 60
B3 = 59
A3s = 58
A3 = 57
G3s = 56
G3 = 55
F3s = 54
F3 = 53
E3 = 52
D3s = 51
D3 = 50
C3s = 49
C3 = 48
B2 = 47
A2s = 46
A2 = 45
G2s = 44
G2 = 43
F2s = 42
F2 = 41
E2 = 40
D2s = 39
D2 = 38
C2s = 37
C2 = 36
B1 = 35
A1s = 34
A1 = 33
G1s = 32
G1 = 31
F1s = 30
F1 = 29
E1 = 28
D1s = 27
D1 = 26
C1s = 25
C1 = 24
B0 = 23
A0s = 22
A0 = 21
End Enum

Each note needs to play for a specified duration. The standard note durations are represented in the following enum:

Public Enum NoteDuration
ThirtysecondthNode = 31
SixteenthNote = 62
EigthNote = 125
QuarterNote = 250
HalfNote = 500
WholeNote = 1000
End Enum

Finally we need a class to encapsulate a note, or notes, with a duration. We can define this class as a "chord":

Public Class Chord
Public Property Notes As IEnumerable(Of NoteMidiCode)
Public Property Duration As NoteDuration
Public Property Velocity As Byte = 127
Public ReadOnly Property IsRest As Boolean
Get
If Notes?.Count = 1 AndAlso Notes.First = NoteMidiCode.Rest Then Return True
Return False
End Get
End Property
End Class

Now we can create a MidiPlayer class capable of opening a device and playing a series of chords on one or more channels.

Option Strict On

Public Class MidiPlayer
Implements IDisposable
Public Event MidiMessageReceived As EventHandler(Of MidiMessageReceivedEventArgs)

Public Property Device As PlaybackDevice
Public ReadOnly Property IsOpen As Boolean

Protected handle As Integer
Private messageHandler As NativeMethods.MidiOutProc

Public Sub New()
Device = PlaybackDevice.DefaultDevice
messageHandler = New NativeMethods.MidiOutProc(AddressOf OnMidiMessageReceived)
End Sub

Protected Overridable Sub OnMidiMessageReceived(handle As Integer, msg As Integer, instance As Integer, param1 As Integer, param2 As Integer)
RaiseEvent MidiMessageReceived(Me, New MidiMessageReceivedEventArgs(handle, msg, instance, param1, param2))
End Sub

Public Overridable Sub Close()
If IsOpen Then
NativeMethods.ResetPlaybackDevice(handle)
NativeMethods.ClosePlaybackDevice(handle)
_IsOpen = False
End If
End Sub

Public Overridable Sub Open()
NativeMethods.OpenPlaybackDevice(handle, Device.DeviceId, Nothing,
0, NativeMethods.CALLBACK_FUNCTION)
_IsOpen = True
End Sub

Public Overridable Async Function Play(channel As Byte, chords As IEnumerable(Of Chord)) As Task
If Device IsNot Nothing Then
For Each chord In chords
Try
If Not chord.IsRest Then
For i = 0 To chord.Notes.Count - 1
Dim note = chord.Notes.ElementAt(i)
SendMessage(New MidiShortMessage(MidiShortMessage.MessageCommandMask.NoteOn, channel, note, chord.Velocity))
Next
End If
Await Task.Delay(chord.Duration)
If Not chord.IsRest Then
For i = 0 To chord.Notes.Count - 1
Dim note = chord.Notes.ElementAt(i)
SendMessage(New MidiShortMessage(MidiShortMessage.MessageCommandMask.NoteOn, channel, note, 0))
Next
End If
Catch disposed_ex As ObjectDisposedException
Exit For
Catch ex As Exception
Throw
End Try
Next
End If
End Function

Public Overridable Async Function Play(channels As Dictionary(Of Byte, IEnumerable(Of Chord))) As Task
If Device IsNot Nothing Then
Dim tasks As New List(Of Task)
For Each channel In channels
tasks.Add(Task.Run(Async Function()
Await Play(channel.Key, channel.Value)
End Function))
Next
Await Task.WhenAll(tasks)
End If
End Function

Protected Overridable Sub SendMessage(message As MidiShortMessage)
NativeMethods.SendPlaybackDeviceMessage(handle, message)
End Sub

Public Overridable Sub SetVoice(channel As Byte, voice As InstrumentVoice)
SendMessage(New MidiShortMessage(MidiShortMessage.MessageCommandMask.ProgramChange, channel, voice, 0))
End Sub

#Region "IDisposable Support"
Private disposedValue As Boolean
Protected Overridable Sub Dispose(disposing As Boolean)
If Not disposedValue Then
If disposing Then
messageHandler = Nothing
Close()
End If
End If
disposedValue = True
End Sub
Public Sub Dispose() Implements IDisposable.Dispose
Dispose(True)
End Sub
#End Region
End Class

Public Class MidiMessageReceivedEventArgs
Inherits EventArgs

Public Handle As Integer, Message As Integer, Instance As Integer, Param1 As Integer, Param2 As Integer

Public Sub New(hnd As Integer, msg As Integer, inst As Integer, p1 As Integer, p2 As Integer)
Handle = hnd
Message = msg
Instance = inst
Param1 = p1
Param2 = p2
End Sub
End Class

This implementation requires that all channels have the same number of notes. While this isn't necessarily conducive to real-world music composition, it can be accommodated with rest notes and careful adjustment of note durations. A more sophisticated player could implement a timeline by which to play each channel's notes.

To facilitate creating MIDI music from code, an additional helper class can be created to parse a string representation of music notes into MIDI data. There are any number of ways one might represent the notes, but this example uses upper case letters (CDEFGAB) followed by an optional # for sharps, followed by (whqest) for whole, half, quarter, eighth, sixteenth or thirty-seconds notes. Parts can be separated by whitespace or punctuation (,; etc). Chords can be combined using parentheses, for example (CEG) for C-major.

Public Class Tune
Inherits ObjectModel.Collection(Of Chord)

Public Overloads Sub Add(notes As IEnumerable(Of NoteMidiCode), duration As NoteDuration)
Add(New Chord() With {.Notes = notes, .Duration = duration})
End Sub

Public Shared Function Parse(tuneData As String) As Tune
Dim result As New Tune
Dim sb As New Text.StringBuilder
Dim i As Integer
Dim octave As Integer = 4
While i < tuneData.Length
sb.Clear()
If IsWhiteSpaceOrPunctuation(tuneData(i)) Then i += 1 : Continue While
If Char.ToUpper(tuneData(i)) = "O"c Then
i += 1
octave = Val(tuneData(i))
i += 1
End If
If IsWhiteSpaceOrPunctuation(tuneData(i)) Then i += 1 : Continue While

Dim tones As IEnumerable(Of NoteMidiCode)
If tuneData(i) = "("c Then
i += 1
Dim noteList As New List(Of NoteMidiCode)
Dim lastNoteChar As Char = Chr(0)
While Not tuneData(i) = ")"c
If IsWhiteSpaceOrPunctuation(tuneData(i)) Then i += 1
Dim noteChar = Char.ToUpper(tuneData(i))
sb.Append(noteChar)
If Not lastNoteChar = Chr(0) AndAlso noteChar > lastNoteChar Then
sb.Append(octave.ToString)
Else
sb.Append((octave + 1).ToString)
End If
lastNoteChar = noteChar
i += 1
If tuneData(i) = "#"c Then
sb.Append("s")
i += 1
End If
noteList.Add(CType([Enum].Parse(GetType(NoteMidiCode), sb.ToString), NoteMidiCode))
sb.Clear()

End While
tones = noteList.ToArray
i += 1
Else
sb.Append(Char.ToUpper(tuneData(i)))
i += 1
If Not sb.Chars(sb.Length - 1) = "R"c Then
sb.Append(octave.ToString)
If tuneData(i) = "#"c Then
sb.Append("s")
i += 1
End If
Else
sb.Append("est")
End If
tones = {CType([Enum].Parse(GetType(NoteMidiCode), sb.ToString), NoteMidiCode)}
End If

If IsWhiteSpaceOrPunctuation(tuneData(i)) Then i += 1

Dim dur As NoteDuration
Select Case tuneData(i)
Case "t"c
dur = NoteDuration.ThirtysecondthNode
Case "s"c
dur = NoteDuration.SixteenthNote
Case "e"c
dur = NoteDuration.EigthNote
Case "q"c
dur = NoteDuration.QuarterNote
Case "h"c
dur = NoteDuration.HalfNote
Case "w"c
dur = NoteDuration.WholeNote
End Select
i += 1

result.Add(tones, dur)
End While
Return result
End Function

Private Shared Function IsWhiteSpaceOrPunctuation(c As Char) As Boolean
If Char.IsWhiteSpace(c) Then Return True
If Char.GetUnicodeCategory(c) = Globalization.UnicodeCategory.OtherPunctuation Then Return True
Return False
End Function
End Class


And there we have it. The ability to play MIDI music from code with direct messages or interpreted strings of note data.

Here's an example of what a simple form using this code might look like:

Imports System.ComponentModel

Public Class Form1
Dim midi As New SimpleMidiEngine.MidiPlayer

Private Async Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
Button1.Enabled = False
Dim score As New Dictionary(Of Byte, IEnumerable(Of SimpleMidiEngine.Chord))
score(1) = SimpleMidiEngine.Tune.Parse(RichTextBox1.Text)
score(2) = score(1)
Await midi.Play(score)
Button1.Enabled = True
End Sub

Private Sub Form1_Closing(sender As Object, e As CancelEventArgs) Handles Me.Closing
midi.Dispose()
End Sub

Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
For Each entry In [Enum].GetValues(GetType(SimpleMidiEngine.InstrumentVoice))
ComboBox1.Items.Add(entry)
Next
ComboBox1.SelectedIndex = 0
ComboBox2.SelectedIndex = 0
midi.Open()
Label2.Text = $"Voices: {midi.Device.VoiceCount}, Notes: {midi.Device.NoteCount}"
End Sub

Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
midi.SetVoice(CInt(ComboBox2.SelectedItem), CType(ComboBox1.SelectedItem, SimpleMidiEngine.InstrumentVoice))
End Sub
End Class

And a little ditty to play might look like:

O3
CqCqCqEeFw
FqFqFqEeCw
CqCqCqEqFqEqFh
FqFqFqEeCw
O5
GqFqGqFqGqFqEh
EqEqEqDh
O3
EqDqEqDqEqDqDqDh
EqEqEqDqCw

Hopefully this example code provides a simple, solid foundation on which to build applications using simple MIDI music generation, or more complex MIDI device implementations.














Reed Kimble - "When you do things right, people won't be sure you've done anything at all"

Continue reading...
 
Back
Top