|
| 1 | +--- |
| 2 | +uid: a_playback_dynamic |
| 3 | +--- |
| 4 | + |
| 5 | +# Dynamic changes |
| 6 | + |
| 7 | +Playback API allows you to modify objects during playback. So you can, for example, add or remove a note or chord or any other MIDI event, or change tempo at some point in time. With this feature it’s possible to even build a MIDI sequencer or piano roll like in your favorite DAW. |
| 8 | + |
| 9 | +Following code is a small demonstration of what you can do: |
| 10 | + |
| 11 | +```csharp |
| 12 | +using Melanchall.DryWetMidi.Common; |
| 13 | +using Melanchall.DryWetMidi.Core; |
| 14 | +using Melanchall.DryWetMidi.Interaction; |
| 15 | +using Melanchall.DryWetMidi.Multimedia; |
| 16 | +using Melanchall.DryWetMidi.MusicTheory; |
| 17 | + |
| 18 | +namespace DynamicPlayback |
| 19 | +{ |
| 20 | + internal class Program |
| 21 | + { |
| 22 | + private static MusicalTimeSpan NoteLength = MusicalTimeSpan.Quarter; |
| 23 | + private static MusicalTimeSpan GapStepLength = MusicalTimeSpan.ThirtySecond; |
| 24 | + |
| 25 | + static void Main(string[] args) |
| 26 | + { |
| 27 | + var tempoMap = TempoMap.Default; |
| 28 | + |
| 29 | + var observableCollection = new ObservableTimedObjectsCollection |
| 30 | + { |
| 31 | + new Scale(ScaleIntervals.Major, NoteName.C) |
| 32 | + .GetAscendingNotes(Octave.Middle.C) |
| 33 | + .Take(10) |
| 34 | + .Select((n, i) => new Melanchall.DryWetMidi.Interaction.Note(n.NoteNumber) |
| 35 | + .SetTime(NoteLength * i, tempoMap) |
| 36 | + .SetLength(NoteLength, tempoMap)) |
| 37 | + }; |
| 38 | + |
| 39 | + var outputDevice = OutputDevice.GetByName("Microsoft GS Wavetable Synth"); |
| 40 | + var playback = new Playback(observableCollection, tempoMap, outputDevice); |
| 41 | + playback.Loop = true; |
| 42 | + |
| 43 | + Console.WriteLine("Press any key to start playback..."); |
| 44 | + Console.ReadKey(); |
| 45 | + |
| 46 | + playback.Start(); |
| 47 | + |
| 48 | + Console.WriteLine("Press ↑ or ↓ to change program number"); |
| 49 | + Console.WriteLine("Press ← or → to change gap between notes"); |
| 50 | + Console.WriteLine("Press any other key to exit..."); |
| 51 | + |
| 52 | + HandleChanges(observableCollection, tempoMap); |
| 53 | + |
| 54 | + playback.Dispose(); |
| 55 | + outputDevice.Dispose(); |
| 56 | + } |
| 57 | + |
| 58 | + private static void HandleChanges( |
| 59 | + ObservableTimedObjectsCollection observableCollection, |
| 60 | + TempoMap tempoMap) |
| 61 | + { |
| 62 | + TimedEvent programChangeTimedEvent = null; |
| 63 | + |
| 64 | + var currentGapSteps = 0; |
| 65 | + var currentProgram = SevenBitNumber.MinValue; |
| 66 | + |
| 67 | + while (true) |
| 68 | + { |
| 69 | + var key = Console.ReadKey().Key; |
| 70 | + |
| 71 | + switch (key) |
| 72 | + { |
| 73 | + case ConsoleKey.UpArrow: |
| 74 | + case ConsoleKey.DownArrow: |
| 75 | + HandleProgramChange(key, ref currentProgram, ref programChangeTimedEvent, observableCollection); |
| 76 | + break; |
| 77 | + case ConsoleKey.LeftArrow: |
| 78 | + case ConsoleKey.RightArrow: |
| 79 | + HandleGapChange(key, ref currentGapSteps, observableCollection, tempoMap); |
| 80 | + break; |
| 81 | + default: |
| 82 | + return; |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + private static void HandleProgramChange( |
| 88 | + ConsoleKey key, |
| 89 | + ref SevenBitNumber program, |
| 90 | + ref TimedEvent programChangeTimedEvent, |
| 91 | + ObservableTimedObjectsCollection observableCollection) |
| 92 | + { |
| 93 | + program = key == ConsoleKey.UpArrow |
| 94 | + ? (SevenBitNumber)Math.Min(SevenBitNumber.MaxValue, program + 1) |
| 95 | + : (SevenBitNumber)Math.Max(SevenBitNumber.MinValue, program - 1); |
| 96 | + |
| 97 | + Console.WriteLine($"New program number: {program}"); |
| 98 | + |
| 99 | + if (programChangeTimedEvent == null) |
| 100 | + { |
| 101 | + programChangeTimedEvent = new TimedEvent(new ProgramChangeEvent(program), 0); |
| 102 | + observableCollection.Add(programChangeTimedEvent); |
| 103 | + } |
| 104 | + else |
| 105 | + { |
| 106 | + var programNumber = program; |
| 107 | + observableCollection.ChangeObject( |
| 108 | + programChangeTimedEvent, |
| 109 | + obj => ((ProgramChangeEvent)((TimedEvent)obj).Event).ProgramNumber = programNumber); |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + private static void HandleGapChange( |
| 114 | + ConsoleKey key, |
| 115 | + ref int gapSteps, |
| 116 | + ObservableTimedObjectsCollection observableCollection, |
| 117 | + TempoMap tempoMap) |
| 118 | + { |
| 119 | + gapSteps = key == ConsoleKey.LeftArrow |
| 120 | + ? Math.Max(0, gapSteps - 1) |
| 121 | + : gapSteps + 1; |
| 122 | + |
| 123 | + Console.WriteLine($"New gap between notes: {gapSteps}"); |
| 124 | + |
| 125 | + var gapStepsNumber = gapSteps; |
| 126 | + |
| 127 | + var i = 0; |
| 128 | + |
| 129 | + foreach (var note in observableCollection.OfType<Melanchall.DryWetMidi.Interaction.Note>()) |
| 130 | + { |
| 131 | + observableCollection.ChangeObject( |
| 132 | + note, |
| 133 | + _ => note.SetTime((NoteLength + GapStepLength * gapStepsNumber) * i, tempoMap)); |
| 134 | + |
| 135 | + i++; |
| 136 | + } |
| 137 | + } |
| 138 | + } |
| 139 | +} |
| 140 | +``` |
| 141 | + |
| 142 | +What does this console application do? Well, first of all it creates a set of notes (which is 10 steps of major scale starting from _C_) and then starts playing them in a loop. Interesting thing here is we use [ObservableTimedObjectsCollection](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollection) – a collection which allows us to modify those notes on the fly and even add new MIDI data to the current playback object: |
| 143 | + |
| 144 | +* Press the **up** or **down** arrow key and the current program number will be **incremented** or **decremented**, so a new instrument will be used to make sound. To apply a program we need to add a [Program Change](xref:Melanchall.DryWetMidi.Core.ProgramChangeEvent) event. If it already exists, we’ll just modify its [ProgramNumber](xref:Melanchall.DryWetMidi.Core.ProgramChangeEvent.ProgramNumber) property. |
| 145 | +* Press the **right** or **left** arrow and the gap between notes will be **incremented** or **decremented**. So we’re modifying notes times here. |
| 146 | + |
| 147 | +## IObservableTimedObjectsCollection |
| 148 | + |
| 149 | +In fact, to enable tracking of data changes you need to create an instance of the [Playback](xref:Melanchall.DryWetMidi.Multimedia.Playback) passing to its constructor an object which type implements two interfaces: |
| 150 | + |
| 151 | +1. `IEnumerable<ITimedObject>` (just because `Playback`’s constructor accepts an argument of this type); |
| 152 | +2. [IObservableTimedObjectsCollection](xref:Melanchall.DryWetMidi.Interaction.IObservableTimedObjectsCollection). |
| 153 | + |
| 154 | +When you modify a collection, it will fire the [CollectionChanged](xref:Melanchall.DryWetMidi.Interaction.IObservableTimedObjectsCollection.CollectionChanged) event holding information about what objects were added, removed and changed. |
| 155 | + |
| 156 | +[ObservableTimedObjectsCollection](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollection) is a built-in type that meets two requirements above. It obviously has methods like [Add](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollection.Add*) or [Remove](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollection.Remove*), but also some special ones. First of all, in the example above we use the [ChangeObject](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollection.ChangeObject*) method. It allows you to modify an object and tell the collection that it has been changed in some way. |
| 157 | + |
| 158 | +```csharp |
| 159 | +collection.ChangeObject(chord, obj => |
| 160 | +{ |
| 161 | + var c = (Chord)obj; |
| 162 | + c.Notes.Remove(c.Notes.First()); |
| 163 | +}); |
| 164 | + |
| 165 | +collection.ChangeObject(chord, _ => |
| 166 | +{ |
| 167 | + chord.Channel = (FourBitNumber)5; |
| 168 | +}); |
| 169 | +``` |
| 170 | +Sometimes your logic of collection modification can be complex and/or distributed between different parts of code. In this case it will be more efficient to fire [CollectionChanged](xref:Melanchall.DryWetMidi.Interaction.IObservableTimedObjectsCollection.CollectionChanged) one time when you’re done with the data instead of triggering the event each time you make a change. There is a method for that – [ChangeCollection](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollection.ChangeCollection*): |
| 171 | + |
| 172 | +```csharp |
| 173 | +var tempoMap = TempoMap.Create(Tempo.FromBeatsPerMinute(240)); |
| 174 | +var collection = new ObservableTimedObjectsCollection(); |
| 175 | + |
| 176 | +collection.ChangeCollection(() => |
| 177 | +{ |
| 178 | + AddInitialObjects(collection, tempoMap); |
| 179 | + FilterObjects(collection); |
| 180 | + FixObjects(collection, tempoMap); |
| 181 | +}); |
| 182 | + |
| 183 | +private static void AddInitialObjects( |
| 184 | + ObservableTimedObjectsCollection collection, |
| 185 | + TempoMap tempoMap) |
| 186 | +{ |
| 187 | + collection.Add(SevenBitNumber |
| 188 | + .Values |
| 189 | + .Select((noteNumber, i) => new Note(noteNumber) { Time = i * 100, Length = 200 })); |
| 190 | + |
| 191 | + collection.Add( |
| 192 | + new TimedEvent(new ProgramChangeEvent((SevenBitNumber)7) { Channel = (FourBitNumber)4 }) |
| 193 | + .SetTime(MusicalTimeSpan.Quarter, tempoMap), |
| 194 | + new Chord( |
| 195 | + new Note((SevenBitNumber)80), |
| 196 | + new Note((SevenBitNumber)90)) |
| 197 | + { |
| 198 | + Channel = (FourBitNumber)4 |
| 199 | + }); |
| 200 | +} |
| 201 | + |
| 202 | +private static void FilterObjects( |
| 203 | + ObservableTimedObjectsCollection collection) |
| 204 | +{ |
| 205 | + var objectsToRemove = collection |
| 206 | + .Where(obj => obj is Note note && note.NoteNumber % 2 == 0) |
| 207 | + .ToList(); |
| 208 | + |
| 209 | + collection.Remove(objectsToRemove); |
| 210 | +} |
| 211 | + |
| 212 | +private static void FixObjects( |
| 213 | + ObservableTimedObjectsCollection collection, |
| 214 | + TempoMap tempoMap) |
| 215 | +{ |
| 216 | + new Quantizer().Quantize( |
| 217 | + collection, |
| 218 | + new SteppedGrid(new MetricTimeSpan(0, 0, 1)), |
| 219 | + tempoMap); |
| 220 | +} |
| 221 | +``` |
| 222 | + |
| 223 | +If you write just |
| 224 | + |
| 225 | +```csharp |
| 226 | +AddInitialObjects(collection, tempoMap); |
| 227 | +FilterObjects(collection); |
| 228 | +FixObjects(collection, tempoMap); |
| 229 | +``` |
| 230 | + |
| 231 | +it will trigger playback’s logic of data tracking multiple times which will degrade performance (you may notice lags in playback). |
| 232 | + |
| 233 | +Of course you can create your own class implementing `IObservableTimedObjectsCollection` (and `IEnumerable<ITimedObject>`) and pass this collection to `Playback`. As the `ObservableTimedObjectsCollection` described above, your class should fire `CollectionChanged` event which arguments object ([ObservableTimedObjectsCollectionChangedEventArgs](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollectionChangedEventArgs)) contains following data: |
| 234 | + |
| 235 | +* [AddedObjects](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollectionChangedEventArgs.AddedObjects); |
| 236 | +* [RemovedObjects](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollectionChangedEventArgs.RemovedObjects); |
| 237 | +* [ChangedObjects](xref:Melanchall.DryWetMidi.Interaction.ObservableTimedObjectsCollectionChangedEventArgs.ChangedObjects). |
| 238 | + |
| 239 | +First and second properties provide a collection of [ITimedObject](xref:Melanchall.DryWetMidi.Interaction.ITimedObject) objects which have been added and removed. Third property contains instances of the [ChangedTimedObject](xref:Melanchall.DryWetMidi.Interaction.ChangedTimedObject) holding an object along with its old time (time before changing). |
| 240 | + |
| 241 | +## Objects references |
| 242 | + |
| 243 | +Note that you must use original objects references when you’re working with an observable collection. For example, with this code |
| 244 | + |
| 245 | +```csharp |
| 246 | +var collection = new ObservableTimedObjectsCollection |
| 247 | +{ |
| 248 | + new TimedEvent(new TextEvent("A"), 100) |
| 249 | +}; |
| 250 | + |
| 251 | +var removed = collection.Remove(new TimedEvent(new TextEvent("A"), 100)); |
| 252 | +``` |
| 253 | + |
| 254 | +`removed` variable will have `false` value since we’re passing a new reference to the `Remove` method. Proper way is |
| 255 | + |
| 256 | +```csharp |
| 257 | +var timedEvent = new TimedEvent(new TextEvent("A"), 100); |
| 258 | +var collection = new ObservableTimedObjectsCollection |
| 259 | +{ |
| 260 | + timedEvent |
| 261 | +}; |
| 262 | + |
| 263 | +var removed = collection.Remove(timedEvent); |
| 264 | +``` |
| 265 | + |
| 266 | +or |
| 267 | + |
| 268 | +```csharp |
| 269 | +var collection = new ObservableTimedObjectsCollection |
| 270 | +{ |
| 271 | + new TimedEvent(new TextEvent("A"), 100) |
| 272 | +}; |
| 273 | + |
| 274 | +var removed = collection.Remove(collection.First()); |
| 275 | +``` |
| 276 | + |
| 277 | +The same situation with the `ChangeObject` method. |
| 278 | + |
| 279 | +## Orphaned Note On and Note Off events |
| 280 | + |
| 281 | +Also we need to discuss a case when you add a [Note On](xref:Melanchall.DryWetMidi.Core.NoteOnEvent) or [Note Off](xref:Melanchall.DryWetMidi.Core.NoteOffEvent) event instead of a Note object. If you add a _Note On_ event, no note will be built internally until you add corresponding (with the same note number and channel) _Note Off_ event. The same is true if you add a _Note Off_ event – you need to add corresponding _Note On_ one to get the note and to have it played. |
| 282 | + |
| 283 | +So with this code |
| 284 | + |
| 285 | +```csharp |
| 286 | +collection.Add(new TimedEvent( |
| 287 | + new NoteOnEvent((SevenBitNumber)70, SevenBitNumber.MaxValue), |
| 288 | + 100)); |
| 289 | +``` |
| 290 | + |
| 291 | +you won’t hear the note until |
| 292 | + |
| 293 | +```csharp |
| 294 | +collection.Add(new TimedEvent( |
| 295 | + new NoteOffEvent((SevenBitNumber)70, SevenBitNumber.MinValue), |
| 296 | + 200)); |
| 297 | +``` |
| 298 | + |
| 299 | +## Tempo map |
| 300 | + |
| 301 | +Obviously you can add a new [Set Tempo](xref:Melanchall.DryWetMidi.Core.SetTempoEvent) event or modify an existing one. But what happens within playback when you do that? |
| 302 | + |
| 303 | +First of all, the process of scaling events times begins. So it can take some time to process events after a tempo change. Of course, only data **after** a tempo change will be scaled. |
| 304 | + |
| 305 | +For example, if we have this initial collection |
| 306 | + |
| 307 | +```csharp |
| 308 | +var collection = new ObservableTimedObjectsCollection |
| 309 | +{ |
| 310 | + new Note((SevenBitNumber)90) { Time = 300, Length = 500 }, |
| 311 | +}; |
| 312 | +``` |
| 313 | + |
| 314 | +and default tempo map (`500000` microseconds per quarter note or `120` BPM), then playback events are: |
| 315 | + |
| 316 | +* _Note On_ at `300` ticks; |
| 317 | +* _Note Off_ at `800` ticks. |
| 318 | + |
| 319 | +Now we want to add a _Set Tempo_ event: |
| 320 | + |
| 321 | +```csharp |
| 322 | +collection.Add(new TimedEvent(new SetTempoEvent(250000), 100)); |
| 323 | +``` |
| 324 | + |
| 325 | +This change makes tempo 2x faster and playback events will be: |
| 326 | + |
| 327 | +* _Set Tempo_ at `100` ticks; |
| 328 | +* _Note On_ at `200` ticks; |
| 329 | +* _Note Off_ at `450` ticks. |
| 330 | + |
| 331 | +So time spans between the tempo change and events become 2x shorter (so they will be played earlier making playback faster). |
| 332 | + |
| 333 | +It's important to note that when you change the tempo map, some time-based things other than MIDI events within the playback will be scaled too. To explain this better, let's take a look at the current time of the playback which can be accessed via [GetCurrentTime](xref:Melanchall.DryWetMidi.Multimedia.Playback.GetCurrentTime*) methods. For example, we have such a playback state: |
| 334 | + |
| 335 | + |
| 336 | + |
| 337 | +If we change the _Set Tempo_ event so that the tempo will be 180 BPM (instead of current 90 BPM), the data will be shrinked by two times: |
| 338 | + |
| 339 | + |
| 340 | + |
| 341 | +As you can see, the current time has been scaled too. Initially it was at the middle of the note. Scaling of the current time ensures it remains at the middle of that note so the playback will be smooth and without unexpected time jumps. |
| 342 | + |
| 343 | +To preserve relative positions, following properties will be scaled too: |
| 344 | + |
| 345 | +* [PlaybackStart](xref:Melanchall.DryWetMidi.Multimedia.Playback.PlaybackStart); |
| 346 | +* [PlaybackEnd](xref:Melanchall.DryWetMidi.Multimedia.Playback.PlaybackEnd); |
| 347 | +* times of snap points. |
| 348 | + |
| 349 | +When you use `Playback` created with an observable collection, you should use [TempoMap](xref:Melanchall.DryWetMidi.Multimedia.Playback.TempoMap) property of the `Playback` instance when you work with its objects. When you edit a tempo map (via adding, removing or changing _Set Tempo_ and _Time Signature_ events), this property will reflect these changes. So the property holds the actual [tempo map](xref:a_tempo_map) of the playback: |
| 350 | + |
| 351 | +```csharp |
| 352 | +collection.Add(new TimedEvent(new TextEvent("A")) |
| 353 | + .SetTime(new MetricTimeSpan(0, 0, 1), playback.TempoMap)); |
| 354 | +``` |
0 commit comments