godot: Audio Stream Playback Generator Broken
Godot version
Godot 4.X
System information
Windows 11, Default WASAPI drivers,
Issue description
When running the audio generator demo. using get_stream_playback()
returns null
which causes playback.get_frames_available()
to error out with Attempt to call function 'get_frames_available' in base 'null instance' on a null instance..
I’ve tried writing a condition in the _process()
function to check if it is null
and retry getting the playback node. After a few cycles, it will return positive but will only fill the buffer once and will not process the frames any further, leaving playback.get_frames_available()
to return 0
for the remainder of the runtime.
Code:
extends Node
var sample_hz = 44100/4.0 # Keep the number of samples to mix low, GDScript is not super fast.
var pulse_hz = 440.0
var phase = 0.0
var playback: AudioStreamPlayback = null # Actual playback stream, assigned in _ready().
func _fill_buffer():
var increment = pulse_hz / sample_hz
var to_fill = playback.get_frames_available()
print(to_fill)
var buffer : PackedVector2Array
while to_fill > 0:
playback.push_frame(Vector2.ONE * sin(phase * TAU)) # Audio frames are stereo.
phase = fmod(phase + increment, 1.0)
to_fill -= 1
func _process(_delta):
if playback != null: _fill_buffer()
else:
print(playback)
_ready()
func _ready():
$Player.stream.mix_rate = sample_hz # Setting mix rate is only possible before play().
playback = $Player.get_stream_playback()
if playback != null: _fill_buffer() # Prefill, do before play() to avoid delay.
$Player.play()
Log:
--- Debugging process started ---
Godot Engine v4.0.alpha14.official.106b68050 - https://godotengine.org
Vulkan API 1.2.0 - Using Vulkan Device #0: NVIDIA - NVIDIA GeForce RTX 2070 SUPER
[Object:null]
[Object:null]
8191
0
128
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
Steps to reproduce
Open Audio Generator Demo in any Godot 4,X version https://github.com/godotengine/godot-demo-projects/tree/3.4-b0d4a7c/audio/generator
Minimal reproduction project
About this issue
- Original URL
- State: closed
- Created 2 years ago
- Reactions: 2
- Comments: 15 (8 by maintainers)
I have investigated this a little bit and found out that the commit that broke the audio stream generator is afd2bbaa5f, i.e., this PR https://github.com/godotengine/godot/pull/55846. The commit immediately before (3017530e26) still works fine.
After skimming the code and adding some debug statements, I have a rough understanding of what is happening: The reason why it sometimes works and sometimes doesn’t is a race condition between the audio server thread and the rendering thread. Typically user code calls
playback.push_buffer()
(ifplayback.get_frames_available()
is non-zero) from the_process
callback. The determining factor whether it works is whether the render thread (i.e., the_process
function) gets to push something into buffer beforeAudioServer::_mix_step
calls intoAudioStreamPlaybackResampled::mix
in the audio thread. If the_process
function fills the buffer first, it works. However, if the audio server tries to “mix” from the playback while the buffer is still empty, it leads to an immediate erasure of the playback from thatplayback_list
in the audio server. This happens because there is some logic that compares the requested number of frames with what actually has been produced by the playback. That’s the crucial part ofAudioServer::_mix_step
:https://github.com/godotengine/godot/blob/1d0e7f0222e2f6da16bea959a4170ac754110d56/servers/audio_server.cpp#L351-L371
The
buffer_size
is always hard coded to 512 currently. Interestingly when callingstream_playback->mix
in line 351 it seems to return 128 frames, even if the rendering thread has pushed nothing into the buffer so far (I still need to understand how that is supposed to work, and why it is always 128). Then in line 357 the comparisonmixed_frames != buffer_size
becomes128 != 512
, so we change thestate
of the playback toAWAITING_DELETION
in line 369-370. Further down inAudioServer::_mix_step
there is a cleanup functionality the erases playbacks from theplayback_list
that are in theAWAITING_DELETION
/FADE_OUT_TO_DELETION
.https://github.com/godotengine/godot/blob/1d0e7f0222e2f6da16bea959a4170ac754110d56/servers/audio_server.cpp#L448-L458
So in summary, if the user thread doesn’t manage to push something into the generator playback, the audio server will immediately remove the generator playback the first time it renders an audio buffer.
Now the question is what’s the best option to resolve the issue. Since I’ve only read Godot’s audio code for a few minutes now and don’t understand the general design yet, I can only make some guesses. Would be great if one of the audio devs could chime in. Some thoughts:
AudioStreamPlaybackResampled::mix
should be adapted to always return the number of requested frames, no matter what. This would mean that it would have to generate silence if it doesn’t have the data available. Basically the typical buffer under-run effect.mixed_frames != buffer_size
it could rather ask the playback “are you still playing?” or so. Perhaps this could even be implemented based on someget_stream_length
function, i.e., finite streams could tell their finite lengths, while infinite streams could indicate that they are infinite by returning e.g.-1
. Or perhaps just aplayback_stream.is_infinite()
getter. Some of these solutions need to be considered carefully, because the call into the playback would then come from the audio server, i.e., a different thread, so any kind of querying function needs to be thread safe.The PR that broke the behavior (https://github.com/godotengine/godot/pull/55846) actually modified
AudioStreamPlaybackResampled::mix
, which could indicate that this is the right place to fix it (i.e., keep themixed_frames != buffer_size
in the audio server as it is). I don’t really understand yet why this PR modified that function in the way it did, and why this change was necessary for fixing ogg edge cases. So simply reverting it would fix the generator playback, but presumably break something related to ogg which I don’t understand yet.The simplest thing ended up working. @bluenote10 could you test #73162?
This makes sense.
This would probably just be fine, because you can still just call
stop()
to begin deletion I think. I don’t have the code in front of me but I’m 90% sure that would work. If youplay()
a generator stream and then never fill it, it will stay in the playing state forever (untilstop()
) but I don’t really think that’s a problem.I’ve been pretty out of the loop with what’s going on with releases and stuff so I don’t know the criteria for inclusion of a bugfix in the RC’s, but this may (?) have to wait for a 4.0.x patch release because it could be a medium-risk change depending on how it’s implemented. I’m up for trying to fix it today though, this piece of code has been bugging me for a while.
I decided to take a deep dive into the problems I was having this afternoon and made a minimal reproduction project with some of the code I was tinkering with. I’ve included it below. Apologies if the evil music generated makes your ears bleed.
Audio.zip
In the default configuration, the project is likely to play on startup. Of note–like many of you, I’ve noticed that, on startup, sound may “chirp” briefly as the initial audio buffer is consumed and overtaken by permanent silence. However, in my experience, it’s totally random whether generated audio chirps and dies or continues to play on in good health. I have found this is mostly affected by the sample rate.
For me
On the other hand, I’ve had the sample rate as low as 4000hz and still had the audio randomly chirp and die. I haven’t found any way to recover from this error either, so generated audio is a non-starter until it’s fixed.
The length of the audio buffer, and how many samples it receives in ready() may also matter, but I might as well be talking about superstition and witchcraft. I might trying straining at the source code tomorrow to see if there are any obvious problems I can understandstand related to to the sample rate.
I’ve noted that the auto play workaround is spotty. If streaming a simple sine wave it’s about 50/50. When loading a file first, then starting to stream, it basically never works. I did get it to work at 50/50 by streaming silence until the file was loaded, then switching to that. It seems possible that the sooner you start streaming the more likely it is to work.
Also I wanted to add that this is great work on your part @bluenote10 tracking down the source of this issue. I scrolled up to the top and was surprised to see the way this issue ended up presenting itself. The cause is not at all obvious.
I have been playing with the audio generator as well. I also noticed that setting autoplay to true “fixed” the issue by allowing the audio to play for just a fraction of a second. However, I was able to get the demo to play a prolonged note by also commenting out the
$Player.play()
invocation, (in the demo of course).Garuda Linux, Godot 4.0.beta3
I can confirm this also.
However, setting Autoplay to True does not really fix the problem for me. It gets rid of the error, but the sound will only play for a fraction of a second before cutting off. In Godot 3.5 the tone continues to play until you exit.
Godot v3.5.stable.official [991bb6ac7] Godot v4.0.beta2.official [f8745f2f7] Windows 10 Pro, Microsoft audioendpoint 10.0.19041.1
EDIT I did a little more playing around. Here is the code that I used for the tests. Code:
It prints out the playback object until it’s not null. Then it prints it one last time. Then it prints out how much of the buffer needs filling.
Godot 4 Output without AutoPlay:
Godot 4 Output with AutoPlay = true:
Godot 3.5 Output:
I think the problem in Godot 4 is two-fold.
Hopefully this helps somehow.
I can confirm this issue as well in Godot 4. I was able to resolve by setting Autoplay to True on the AudioStreamPlayer object – rest of the code works fine after that change. I can also confirm when opening the tutorial project in Godot 3.4 they do NOT set Autoplay to True. Not sure if a bug or not, but hope this helps!