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
4#nullable enable
5
6using ManagedBass;
7using osu.Framework.Audio.Mixing.Bass;
8using osu.Framework.Audio.Track;
9using osu.Framework.Extensions.ObjectExtensions;
10
11namespace osu.Framework.Audio.Sample
12{
13 internal sealed class SampleChannelBass : SampleChannel, IBassAudioChannel
14 {
15 private readonly SampleBass sample;
16 private volatile int channel;
17
18 /// <summary>
19 /// Whether the channel is currently playing.
20 /// </summary>
21 /// <remarks>
22 /// This is set to <c>true</c> immediately upon <see cref="Play"/>, but the channel may not be audibly playing yet.
23 /// </remarks>
24 public override bool Playing => playing || enqueuedPlaybackStart;
25
26 private volatile bool playing;
27
28 /// <summary>
29 /// <c>true</c> if the user last called <see cref="Play"/>.
30 /// <c>false</c> if the user last called <see cref="Stop"/>.
31 /// </summary>
32 private volatile bool userRequestedPlay;
33
34 /// <summary>
35 /// Whether the playback start has been enqueued.
36 /// </summary>
37 private volatile bool enqueuedPlaybackStart;
38
39 private readonly BassRelativeFrequencyHandler relativeFrequencyHandler;
40 private BassAmplitudeProcessor? bassAmplitudeProcessor;
41
42 /// <summary>
43 /// Creates a new <see cref="SampleChannelBass"/>.
44 /// </summary>
45 /// <param name="sample">The <see cref="SampleBass"/> to create the channel from.</param>
46 public SampleChannelBass(SampleBass sample)
47 {
48 this.sample = sample;
49
50 relativeFrequencyHandler = new BassRelativeFrequencyHandler
51 {
52 FrequencyChangedToZero = stopChannel,
53 FrequencyChangedFromZero = () =>
54 {
55 // Only unpause if the channel has been played by the user.
56 if (userRequestedPlay)
57 playChannel();
58 },
59 };
60
61 ensureChannel();
62 }
63
64 public override void Play()
65 {
66 userRequestedPlay = true;
67
68 // Pin Playing and IsAlive to true so that the channel isn't killed by the next update. This is only reset after playback is started.
69 enqueuedPlaybackStart = true;
70
71 // Bring this channel alive, allowing it to receive updates.
72 base.Play();
73
74 playChannel();
75 }
76
77 internal override void OnStateChanged()
78 {
79 base.OnStateChanged();
80
81 if (!hasChannel)
82 return;
83
84 Bass.ChannelSetAttribute(channel, ChannelAttribute.Volume, AggregateVolume.Value);
85 Bass.ChannelSetAttribute(channel, ChannelAttribute.Pan, AggregateBalance.Value);
86 relativeFrequencyHandler.SetFrequency(AggregateFrequency.Value);
87 }
88
89 public override bool Looping
90 {
91 get => base.Looping;
92 set
93 {
94 base.Looping = value;
95 setLoopFlag(Looping);
96 }
97 }
98
99 protected override void UpdateState()
100 {
101 if (hasChannel)
102 {
103 switch (bassMixer.ChannelIsActive(this))
104 {
105 case PlaybackState.Playing:
106 // Stalled counts as playing, as playback will continue once more data has streamed in.
107 case PlaybackState.Stalled:
108 // The channel is in a "paused" state via zero-frequency. It should be marked as playing even if it's in a paused state internally.
109 case PlaybackState.Paused when userRequestedPlay:
110 playing = true;
111 break;
112
113 default:
114 playing = false;
115 break;
116 }
117 }
118 else
119 {
120 // Channel doesn't exist - a rare case occurring as a result of device updates.
121 playing = false;
122 }
123
124 base.UpdateState();
125
126 bassAmplitudeProcessor?.Update();
127 }
128
129 public override void Stop()
130 {
131 userRequestedPlay = false;
132
133 base.Stop();
134
135 stopChannel();
136 }
137
138 public override ChannelAmplitudes CurrentAmplitudes => (bassAmplitudeProcessor ??= new BassAmplitudeProcessor(this)).CurrentAmplitudes;
139
140 private bool hasChannel => channel != 0;
141
142 private void playChannel() => EnqueueAction(() =>
143 {
144 try
145 {
146 // Channel may have been freed via UpdateDevice().
147 ensureChannel();
148
149 if (!hasChannel)
150 return;
151
152 // Ensure state is correct before starting.
153 InvalidateState();
154
155 // Bass will restart the sample if it has reached its end. This behavior isn't desirable so block locally.
156 // Unlike TrackBass, sample channels can't have sync callbacks attached, so the stopped state is used instead
157 // to indicate the natural stoppage of a sample as a result of having reaching the end.
158 if (Played && bassMixer.ChannelIsActive(this) == PlaybackState.Stopped)
159 return;
160
161 playing = true;
162
163 if (!relativeFrequencyHandler.IsFrequencyZero)
164 bassMixer.ChannelPlay(this);
165 }
166 finally
167 {
168 enqueuedPlaybackStart = false;
169 }
170 });
171
172 private void stopChannel() => EnqueueAction(() =>
173 {
174 if (hasChannel)
175 bassMixer.ChannelPause(this);
176 });
177
178 private void setLoopFlag(bool value) => EnqueueAction(() =>
179 {
180 if (hasChannel)
181 Bass.ChannelFlags(channel, value ? BassFlags.Loop : BassFlags.Default, BassFlags.Loop);
182 });
183
184 private void ensureChannel() => EnqueueAction(() =>
185 {
186 if (hasChannel)
187 return;
188
189 channel = Bass.SampleGetChannel(sample.SampleId, BassFlags.SampleChannelStream | BassFlags.Decode);
190
191 if (!hasChannel)
192 return;
193
194 setLoopFlag(Looping);
195
196 relativeFrequencyHandler.SetChannel(channel);
197 });
198
199 #region Mixing
200
201 private BassAudioMixer bassMixer => (BassAudioMixer)Mixer.AsNonNull();
202
203 bool IBassAudioChannel.IsActive => IsAlive;
204
205 int IBassAudioChannel.Handle => channel;
206
207 bool IBassAudioChannel.MixerChannelPaused { get; set; } = true;
208
209 BassAudioMixer IBassAudioChannel.Mixer => bassMixer;
210
211 #endregion
212
213 protected override void Dispose(bool disposing)
214 {
215 if (IsDisposed)
216 return;
217
218 if (hasChannel)
219 {
220 bassMixer.StreamFree(this);
221 channel = 0;
222 }
223
224 playing = false;
225
226 base.Dispose(disposing);
227 }
228 }
229}