A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Collections.Generic;
6using System.Linq;
7using System.Threading;
8using osu.Framework.Allocation;
9using osu.Framework.Audio.Track;
10using osu.Framework.Graphics.Batches;
11using osu.Framework.Graphics.Colour;
12using osu.Framework.Graphics.OpenGL.Vertices;
13using osu.Framework.Graphics.Primitives;
14using osu.Framework.Graphics.Shaders;
15using osu.Framework.Graphics.Textures;
16using osuTK;
17using osu.Framework.Graphics.OpenGL;
18using osu.Framework.Layout;
19using osu.Framework.Utils;
20using osu.Framework.Threading;
21using osuTK.Graphics;
22using RectangleF = osu.Framework.Graphics.Primitives.RectangleF;
23
24namespace osu.Framework.Graphics.Audio
25{
26 /// <summary>
27 /// Visualises the waveform for an audio stream.
28 /// </summary>
29 public class WaveformGraph : Drawable
30 {
31 private IShader shader;
32 private readonly Texture texture;
33
34 public WaveformGraph()
35 {
36 texture = Texture.WhitePixel;
37 }
38
39 [BackgroundDependencyLoader]
40 private void load(ShaderManager shaders)
41 {
42 shader = shaders.Load(VertexShaderDescriptor.TEXTURE_2, FragmentShaderDescriptor.TEXTURE_ROUNDED);
43 }
44
45 private float resolution = 1;
46
47 /// <summary>
48 /// Gets or sets the amount of <see cref="Framework.Audio.Track.Waveform.Point"/>'s displayed relative to <see cref="Drawable.DrawWidth">DrawWidth</see>.
49 /// </summary>
50 public float Resolution
51 {
52 get => resolution;
53 set
54 {
55 if (value < 0)
56 throw new ArgumentOutOfRangeException(nameof(value));
57
58 if (resolution == value)
59 return;
60
61 resolution = value;
62 generate();
63 }
64 }
65
66 private Waveform waveform;
67
68 /// <summary>
69 /// The <see cref="Framework.Audio.Track.Waveform"/> to display.
70 /// </summary>
71 public Waveform Waveform
72 {
73 get => waveform;
74 set
75 {
76 if (waveform == value)
77 return;
78
79 waveform = value;
80 generate();
81 }
82 }
83
84 private Color4 baseColour = Color4.White;
85
86 /// <summary>
87 /// The base colour of the graph for frequencies that don't fall into the predefined low/mid/high buckets.
88 /// Also serves as the default value of <see cref="LowColour"/>, <see cref="MidColour"/>, and <see cref="HighColour"/>.
89 /// </summary>
90 public Color4 BaseColour
91 {
92 get => baseColour;
93 set
94 {
95 if (baseColour == value)
96 return;
97
98 baseColour = value;
99
100 Invalidate(Invalidation.DrawNode);
101 }
102 }
103
104 private Color4? lowColour;
105
106 /// <summary>
107 /// The colour which low-range frequencies should be colourised with.
108 /// May be null for this frequency range to not be colourised.
109 /// </summary>
110 public Color4? LowColour
111 {
112 get => lowColour;
113 set
114 {
115 if (lowColour == value)
116 return;
117
118 lowColour = value;
119
120 Invalidate(Invalidation.DrawNode);
121 }
122 }
123
124 private Color4? midColour;
125
126 /// <summary>
127 /// The colour which mid-range frequencies should be colourised with.
128 /// May be null for this frequency range to not be colourised.
129 /// </summary>
130 public Color4? MidColour
131 {
132 get => midColour;
133 set
134 {
135 if (midColour == value)
136 return;
137
138 midColour = value;
139
140 Invalidate(Invalidation.DrawNode);
141 }
142 }
143
144 private Color4? highColour;
145
146 /// <summary>
147 /// The colour which high-range frequencies should be colourised with.
148 /// May be null for this frequency range to not be colourised.
149 /// </summary>
150 public Color4? HighColour
151 {
152 get => highColour;
153 set
154 {
155 if (highColour == value)
156 return;
157
158 highColour = value;
159
160 Invalidate(Invalidation.DrawNode);
161 }
162 }
163
164 protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source)
165 {
166 var result = base.OnInvalidate(invalidation, source);
167
168 if ((invalidation & Invalidation.RequiredParentSizeToFit) > 0)
169 {
170 generate();
171 result = true;
172 }
173
174 return result;
175 }
176
177 private CancellationTokenSource cancelSource = new CancellationTokenSource();
178 private ScheduledDelegate scheduledGenerate;
179
180 private List<Waveform.Point> resampledPoints;
181 private int resampledChannels;
182 private double resampledMaxHighIntensity;
183 private double resampledMaxMidIntensity;
184 private double resampledMaxLowIntensity;
185
186 private void generate()
187 {
188 scheduledGenerate?.Cancel();
189 cancelGeneration();
190
191 if (Waveform == null)
192 return;
193
194 scheduledGenerate = Schedule(() =>
195 {
196 cancelSource = new CancellationTokenSource();
197 var token = cancelSource.Token;
198
199 Waveform.GenerateResampledAsync((int)Math.Max(0, Math.Ceiling(DrawWidth * Scale.X) * Resolution), token).ContinueWith(w =>
200 {
201 var points = w.Result.GetPoints();
202 var channels = w.Result.GetChannels();
203 var maxHighIntensity = points.Count > 0 ? points.Max(p => p.HighIntensity) : 0;
204 var maxMidIntensity = points.Count > 0 ? points.Max(p => p.MidIntensity) : 0;
205 var maxLowIntensity = points.Count > 0 ? points.Max(p => p.LowIntensity) : 0;
206
207 Schedule(() =>
208 {
209 resampledPoints = points;
210 resampledChannels = channels;
211 resampledMaxHighIntensity = maxHighIntensity;
212 resampledMaxMidIntensity = maxMidIntensity;
213 resampledMaxLowIntensity = maxLowIntensity;
214
215 OnWaveformRegenerated(w.Result);
216
217 Invalidate(Invalidation.DrawNode);
218 });
219 }, token);
220 });
221 }
222
223 private void cancelGeneration()
224 {
225 cancelSource?.Cancel();
226 cancelSource?.Dispose();
227 cancelSource = null;
228 }
229
230 /// <summary>
231 /// Invoked when the waveform has been regenerated.
232 /// </summary>
233 /// <param name="waveform">The new <see cref="Waveform"/> to be displayed.</param>
234 protected virtual void OnWaveformRegenerated(Waveform waveform)
235 {
236 }
237
238 protected override DrawNode CreateDrawNode() => new WaveformDrawNode(this);
239
240 protected override void Dispose(bool isDisposing)
241 {
242 base.Dispose(isDisposing);
243 cancelGeneration();
244 }
245
246 private class WaveformDrawNode : DrawNode
247 {
248 private IShader shader;
249 private Texture texture;
250
251 private readonly List<Waveform.Point> points = new List<Waveform.Point>();
252
253 private Vector2 drawSize;
254 private int channels;
255
256 private Color4 baseColour;
257 private Color4 lowColour;
258 private Color4 midColour;
259 private Color4 highColour;
260
261 private double highMax;
262 private double midMax;
263 private double lowMax;
264
265 protected new WaveformGraph Source => (WaveformGraph)base.Source;
266
267 public WaveformDrawNode(WaveformGraph source)
268 : base(source)
269 {
270 }
271
272 public override void ApplyState()
273 {
274 base.ApplyState();
275
276 shader = Source.shader;
277 texture = Source.texture;
278 drawSize = Source.DrawSize;
279
280 points.Clear();
281
282 if (Source.resampledPoints != null)
283 points.AddRange(Source.resampledPoints);
284
285 channels = Source.resampledChannels;
286 highMax = Source.resampledMaxHighIntensity;
287 midMax = Source.resampledMaxMidIntensity;
288 lowMax = Source.resampledMaxLowIntensity;
289
290 baseColour = Source.baseColour;
291 lowColour = Source.lowColour ?? baseColour;
292 midColour = Source.midColour ?? baseColour;
293 highColour = Source.highColour ?? baseColour;
294 }
295
296 private readonly QuadBatch<TexturedVertex2D> vertexBatch = new QuadBatch<TexturedVertex2D>(1000, 10);
297
298 public override void Draw(Action<TexturedVertex2D> vertexAction)
299 {
300 base.Draw(vertexAction);
301
302 if (texture?.Available != true || points == null || points.Count == 0)
303 return;
304
305 shader.Bind();
306 texture.TextureGL.Bind();
307
308 Vector2 localInflationAmount = new Vector2(0, 1) * DrawInfo.MatrixInverse.ExtractScale().Xy;
309
310 // We're dealing with a _large_ number of points, so we need to optimise the quadToDraw * drawInfo.Matrix multiplications below
311 // for points that are going to be masked out anyway. This allows for higher resolution graphs at larger scales with virtually no performance loss.
312 // Since the points are generated in the local coordinate space, we need to convert the screen space masking quad coordinates into the local coordinate space
313 RectangleF localMaskingRectangle = (Quad.FromRectangle(GLWrapper.CurrentMaskingInfo.ScreenSpaceAABB) * DrawInfo.MatrixInverse).AABBFloat;
314
315 float separation = drawSize.X / (points.Count - 1);
316
317 for (int i = 0; i < points.Count - 1; i++)
318 {
319 float leftX = i * separation;
320 float rightX = (i + 1) * separation;
321
322 if (rightX < localMaskingRectangle.Left)
323 continue;
324
325 if (leftX > localMaskingRectangle.Right)
326 break; // X is always increasing
327
328 Color4 frequencyColour = baseColour;
329
330 // colouring is applied in the order of interest to a viewer.
331 frequencyColour = Interpolation.ValueAt(points[i].MidIntensity / midMax, frequencyColour, midColour, 0, 1);
332 // high end (cymbal) can help find beat, so give it priority over mids.
333 frequencyColour = Interpolation.ValueAt(points[i].HighIntensity / highMax, frequencyColour, highColour, 0, 1);
334 // low end (bass drum) is generally the best visual aid for beat matching, so give it priority over high/mid.
335 frequencyColour = Interpolation.ValueAt(points[i].LowIntensity / lowMax, frequencyColour, lowColour, 0, 1);
336
337 ColourInfo finalColour = DrawColourInfo.Colour;
338 finalColour.ApplyChild(frequencyColour);
339
340 Quad quadToDraw;
341
342 switch (channels)
343 {
344 default:
345 case 2:
346 {
347 float height = drawSize.Y / 2;
348 quadToDraw = new Quad(
349 new Vector2(leftX, height - points[i].Amplitude[0] * height),
350 new Vector2(rightX, height - points[i + 1].Amplitude[0] * height),
351 new Vector2(leftX, height + points[i].Amplitude[1] * height),
352 new Vector2(rightX, height + points[i + 1].Amplitude[1] * height)
353 );
354 break;
355 }
356
357 case 1:
358 {
359 quadToDraw = new Quad(
360 new Vector2(leftX, drawSize.Y - points[i].Amplitude[0] * drawSize.Y),
361 new Vector2(rightX, drawSize.Y - points[i + 1].Amplitude[0] * drawSize.Y),
362 new Vector2(leftX, drawSize.Y),
363 new Vector2(rightX, drawSize.Y)
364 );
365 break;
366 }
367 }
368
369 quadToDraw *= DrawInfo.Matrix;
370 DrawQuad(texture, quadToDraw, finalColour, null, vertexBatch.AddAction, Vector2.Divide(localInflationAmount, quadToDraw.Size));
371 }
372
373 shader.Unbind();
374 }
375
376 protected override void Dispose(bool isDisposing)
377 {
378 base.Dispose(isDisposing);
379
380 vertexBatch.Dispose();
381 }
382 }
383 }
384}