Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move mixing out of the AudioStreamPlayer* nodes #51296

Merged
merged 3 commits into from
Aug 27, 2021

Conversation

ellenhp
Copy link
Contributor

@ellenhp ellenhp commented Aug 5, 2021

Fixes #25087
Fixes #21312

Implements godotengine/godot-proposals#2299

My focus here is parity with current master, not to add features, but it's still an enormous change. I can probably split off the individual commits into their own PRs if people would prefer that.

More detail is in the proposal above, but this basically moves all mixing into the audio server. Audio streams register their playback objects with the audio server and then update the volume during their panning or spatialization steps. This eliminates an entire class of bugs in which the player node's state is updated and it can't continue a volume ramp or fade out.

This also opens the door to lots of really exciting things! Now that we can crossfade across audio buses, we can have Area2D/Area3D based bus overrides that smoothly transition to the new bus. Imagine the mouth of a cave that slowly fades reverb in, or an area of a 2d platformer level that adds a distortion to audio. Before these things would cause pops and abrupt transitions, but with this change the pops are gone and it'll be fairly easy to add smooth transitions.

Another thing we can add back is polyphony, which I think users will appreciate. An audio stream player node can simply instance multiple playbacks for a stream and send them all to the audio server which will mix them independently. That's also not included in this change but should be simple to add.

A few notes:

  • I changed the mix buffer size to 512, which I think is fine. It should probably be a project setting or at least influenced by the audio driver's buffer size, i.e. based on the latency project setting. It doesn't make sense for the audio server to have a bigger buffer than the audio driver.
  • The NO_THREADS implementation of my SafeList data structure is probably broken, but I don't really know how to test it given that web exports aren't working in 4.0 right now, unless I'm mistaken on that.
  • This PR contains a mediocre workaround for AudioStreamPlayer2D doesn't play any audio when played #50958 that I put in for testing purposes only. I'm basically including the current viewport in the list of viewports to iterate through while looking for the active listener. It works for simple scenes but we should still address AudioStreamPlayer2D doesn't play any audio when played #50958 somehow.

@KoBeWi KoBeWi added this to the 4.0 milestone Aug 5, 2021
@ellenhp ellenhp changed the title Mix audio in the audio server Move mixing out of the AudioStreamPlayback* nodes Aug 5, 2021
@ellenhp ellenhp force-pushed the mix_in_audio_server branch 5 times, most recently from 439cd45 to b13e7fd Compare August 8, 2021 14:37
@ellenhp ellenhp marked this pull request as ready for review August 8, 2021 14:59
@ellenhp ellenhp requested review from a team as code owners August 8, 2021 14:59
@ellenhp
Copy link
Contributor Author

ellenhp commented Aug 8, 2021

This is the project I've been using to test: audioservertest.zip

It's probably good to test this on other more complex projects too.

Copy link
Member

@akien-mga akien-mga left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work!

I left some style nitpicks and only skimmed through the detailed code changes, but it looks pretty solid!

// instead the node will be deallocated at a later time when it is safe to do so.
// - No blocking synchronization primitives will be used.

// This is used in very specific areas of the engine where it's critical that these guarantees are held
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// This is used in very specific areas of the engine where it's critical that these guarantees are held
// This is used in very specific areas of the engine where it's critical that these guarantees are held.

struct SafeListNode {
std::atomic<SafeListNode *> next = nullptr;

// If the node is logically deleted, this pointer will typically point to the previous list item in time that was also logically deleted.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a line length limit but for long comments it can be worth wrapping manually:

Suggested change
// If the node is logically deleted, this pointer will typically point to the previous list item in time that was also logically deleted.
// If the node is logically deleted, this pointer will typically point
// to the previous list item in time that was also logically deleted.

// Fall through upon failure, try again.
}
}
// Then queue it for deletion by putting it in the node graveyard. Don't touch `next` because an iterator might still be pointing at this node.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Then queue it for deletion by putting it in the node graveyard. Don't touch `next` because an iterator might still be pointing at this node.
// Then queue it for deletion by putting it in the node graveyard.
// Don't touch `next` because an iterator might still be pointing at this node.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • a few other comments that might be worth wrapping, I'd recommend keeping line length at max 100-120 chars for comments.

}
};

#else
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest putting a comment to remind what this #else is about:

Suggested change
#else
#else // NO_THREADS


int new_output_count = 0;
//stop playing if no longer active
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
//stop playing if no longer active
// Stop playing if no longer active.

} else {
for (int i = 0; i < p_frames; i++) {
p_buffer[i] = AudioFrame(0, 0);
}
// TODO does this make sense?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To assess.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it for a while, and I think it does make sense :) Removed as a todo.

Comment on lines 44 to 46
#include "servers/audio/effects/audio_effect_compressor.h"
#include <cstring>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#include "servers/audio/effects/audio_effect_compressor.h"
#include <cstring>
#include "servers/audio/effects/audio_effect_compressor.h"
#include <cstring>

AUDIO_DATA_INVALID_ID = -1,
MAX_CHANNELS_PER_BUS = 4,
MAX_BUSES_PER_PLAYBACK = 6,
LOOKAHEAD_BUFFER_SIZE = 32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
LOOKAHEAD_BUFFER_SIZE = 32
LOOKAHEAD_BUFFER_SIZE = 32,

Trailing comma can ensure that future additions won't generate unnecessary line diff.

Comment on lines 245 to 248
std::atomic<float> setseek = -1;
std::atomic<float> pitch_scale = 1;
std::atomic<float> highshelf_gain = 0;
std::atomic<float> attenuation_filter_cutoff_hz = 0; // This isn't used unless highshelf_gain is nonzero.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know we tend to use our own SafeNumeric instead of std::atomic (which wraps std::atomic), but I'm not familiar with its semantics and whether it would actually make sense here. CC @RandomShaper

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right. SafeNumeric<T> is intended to be the Godot "STL" counterpart of std::atomic. It should fit most cases, where acquire-release semantics is the right choice.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Elaborating on that, I see atomic are being used throughout this PR will the default memory ordering semantics (sequential). Even if we didn't have SafeNumeric, it'd be good to check if acquire-release is enough, because, AFAIK, that is more performant on certain architectures.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think acquire-release is probably fine everywhere except in SafeList but I find it very difficult to reason about these things so I'm not sure. I do think a system-wide barrier of some sort is appropriate during insertions or removals to SafeList because they tend to be fairly rare and the penalty if something goes wrong could be a double free or a use after free. These scalar values can absolutely have weaker guarantees.

The main thing we're attempting to avoid here is a sleeping on mutex contention, right? A memory barrier is not in the same league performance-wise as sleeping the audio thread.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently a compare-swap operation is about as expensive as a cache miss on x86 server platforms. I'm curious about other architectures but even if it were 100x more expensive than a cache miss I don't think I'd consider moving to another memory model for SafeList given how rarely insertions and deletions happen. I'm also hesitant to optimize without profiling results.

Side-note, thank you so much for reviewing this @RandomShaper. I know it's a difficult PR to review and I appreciate it. :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries! It's also hard for me to reason about every possible case. I just have the mindset of use acq-rel by default, which is already stronger than certain cases would need, but it still allows for more perfomant load/stores on some architectures than sequential would cause. When it's about compare-exchange, to be honest, I still haven't studied much about the performane consequences of using one memory ordering or the other.

In the case of SafeList, I can't but agree that the wisest thing is to leave sequential ordering.

This said, the only thing I'd ask you to do is switch to SafeNumeric in the other cases.

Copy link
Contributor Author

@ellenhp ellenhp Aug 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! I'll do that later today. I think I also want to get my NO_THREADS implementation of the SafeList class to a better state than it is right now. I haven't tested it so it might be broken and I don't want to check in broken code. I can ping you when everything is done if you'd like?

I'm glad I'm not the only one who finds this sort of thing difficult. Writing the SafeList class nearly broke my brain. Lock-free programming is hard. 🤣

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, feel free to ping me.

Yeah, lockless is hell. I wouldn't dare myself to write a lockless data structure unless there's no other option, like basing it on some third party one known to be good.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RandomShaper Ready for another review. I've had lots of trouble thinking about whether or not the remaining atomic usages are okay for acquire-release. The scalar values are definitely okay to be SafeNumerics, but other than those, all remaining usages of the default atomic behavior impact control flow in non-trivial ways. If you'd like, I can switch the playback state enum over to be acquire-release, but I'd prefer to keep the bus details pointer as-is. I should be honest though, it's fear of double-frees or other weird behavior that's making me say this, not any sort of logic or reason. :)

Comment on lines 385 to 387
bool get_is_playback_active(Ref<AudioStreamPlayback> p_playback);
float get_playback_position(Ref<AudioStreamPlayback> p_playback);
bool get_is_playback_paused(Ref<AudioStreamPlayback> p_playback);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For boolean getters, we usually start directly with is_ and not with get_is_.

@ellenhp ellenhp force-pushed the mix_in_audio_server branch 3 times, most recently from 3d9a87f to 0a48adf Compare August 18, 2021 01:58
@ellenhp
Copy link
Contributor Author

ellenhp commented Aug 18, 2021

Made the changes Akien pointed out and rebased. I also added a preprocessor warning in the NO_THREADS implementation of SafeList because it occurred to me that this could cause some frustrating bugs in the future if nobody is aware of it. I used a pattern that I've seen elsewhere in the codebase to avoid throwing an error on MSVC toolchains. I just want it documented in the code that NO_THREADS SafeLists are untested so someone debugging can identify issues quickly. Hopefully that's fine.

I also wanted to ask about whether my silly workaround in AudioStreamPlayer2D is appropriate to keep in this PR. I left it in for ease of testing but I don't think it's the correct solution for #50958.

// Updating this ref after the list node is created breaks consistency guarantees, don't do it!
Ref<AudioStreamPlayback> stream_playback;
// Playback state determines the fate of a particular AudioStreamListNode during the mix step. Must be atomically replaced.
std::atomic<PlaybackState> state = AWAITING_DELETION;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this one can become a SafeNumeric, too, with the same provision about checking that acquire-release is fine.


struct CallbackItem {
std::atomic<CallbackItem *> next;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case, since we don't have a SafePointer<T>, using std::atomic directly is fine. If we find many cases of atomic pointers sometime in the future, we can always introduce it.

In any case, again, it'd be good to check if acquire-release (or even something weaker) can be used intead of sequential in the operations.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioned above, but I'm not entirely comfortable switching over to acquire release here. I'm not convinced it would be a problem, but I can't convince myself it's safe either.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow nevermind! These atomics can be removed. They're dead code leftover from before I factored out SafeList into its own file.

@reduz reduz merged commit 54caaa2 into godotengine:master Aug 27, 2021
@ellenhp ellenhp deleted the mix_in_audio_server branch August 28, 2021 04:32
@AndreaCatania
Copy link
Contributor

Hello, when I play the game I've a segmentation fault at this line: https://github.com/ellenhp/godot/blob/3598d300cb43797a4f18b34d921875d060ce7de7/servers/audio_server.cpp#L1237 which was added by this PR. Any idea how to fix it?

handle_crash: Program crashed with signal 11
Engine version: Godot Engine v4.0.dev.custom_build (e6e5502a165d34762e29203658dbfd624a5bb9bd)
Dumping the backtrace. Please include this when reporting the bug on https://github.com/godotengine/godot/issues
[1] /lib64/libc.so.6(+0x3d320) [0x7ffff7a87320] (??:0)
[2] AudioServer::set_playback_all_bus_volumes_linear(Ref<AudioStreamPlayback>, Vector<AudioFrame>) (/home/andrea/Workspace/momento/wander/source/engine/godot/servers/audio_server.cpp:1237)
[3] AudioStreamPlayer::set_volume_db(float) (/home/andrea/Workspace/momento/wander/source/engine/godot/scene/audio/audio_stream_player.cpp:98 (discriminator 4))
[4] void call_with_variant_args_helper<__UnexistingClass, float, 0ul>(__UnexistingClass*, void (__UnexistingClass::*)(float), Variant const**, Callable::CallError&, IndexSequence<0ul>) (/home/andrea/Workspace/momento/wander/source/engine/godot/./core/variant/binder_common.h:227 (discriminator 4))
[5] void call_with_variant_args_dv<__UnexistingClass, float>(__UnexistingClass*, void (__UnexistingClass::*)(float), Variant const**, int, Callable::CallError&, Vector<Variant> const&) (/home/andrea/Workspace/momento/wander/source/engine/godot/./core/variant/binder_common.h:370)
[6] MethodBindT<float>::call(Object*, Variant const**, int, Callable::CallError&) (/home/andrea/Workspace/momento/wander/source/engine/godot/./core/object/method_bind.h:286)
[7] ClassDB::set_property(Object*, StringName const&, Variant const&, bool*) (/home/andrea/Workspace/momento/wander/source/engine/godot/core/object/class_db.cpp:1189)
[8] Object::set(StringName const&, Variant const&, bool*) (/home/andrea/Workspace/momento/wander/source/engine/godot/core/object/object.cpp:407)
[9] Variant::set_named(StringName const&, Variant const&, bool&) (/home/andrea/Workspace/momento/wander/source/engine/godot/core/variant/variant_setget.cpp:234)
[10] GDScriptFunction::call(GDScriptInstance*, Variant const**, int, Callable::CallError&, GDScriptFunction::CallState*) (/home/andrea/Workspace/momento/wander/source/engine/godot/modules/gdscript/gdscript_vm.cpp:947)
[11] GDScriptInstance::call(StringName const&, Variant const**, int, Callable::CallError&) (/home/andrea/Workspace/momento/wander/source/engine/godot/./modules/gdscript/gdscript.cpp:1486)
[12] Node::_gdvirtual__process_call(double) (/home/andrea/Workspace/momento/wander/source/engine/godot/scene/main/node.h:221 (discriminator 5))
[13] Node::_notification(int) (/home/andrea/Workspace/momento/wander/source/engine/godot/scene/main/node.cpp:59)
[14] Node::_notificationv(int, bool) (/home/andrea/Workspace/momento/wander/source/engine/godot/./scene/main/node.h:48 (discriminator 14))
[15] Object::notification(int, bool) (/home/andrea/Workspace/momento/wander/source/engine/godot/core/object/object.cpp:843)
[16] SceneTree::_notify_group_pause(StringName const&, int) (/home/andrea/Workspace/momento/wander/source/engine/godot/scene/main/scene_tree.cpp:851)
[17] SceneTree::process(double) (/home/andrea/Workspace/momento/wander/source/engine/godot/scene/main/scene_tree.cpp:455 (discriminator 2))
[18] Main::iteration() (/home/andrea/Workspace/momento/wander/source/engine/godot/main/main.cpp:2554)
[19] OS_LinuxBSD::run() (/home/andrea/Workspace/momento/wander/source/engine/godot/platform/linuxbsd/os_linuxbsd.cpp:342)
[20] /home/andrea/Workspace/momento/wander/source/engine/godot/bin/godot.linuxbsd.tools.64(main+0x144) [0x24ad95a] (/home/andrea/Workspace/momento/wander/source/engine/godot/platform/linuxbsd/godot_linuxbsd.cpp:60)
[21] /lib64/libc.so.6(__libc_start_main+0xd5) [0x7ffff7a71b75] (??:0)
[22] /home/andrea/Workspace/momento/wander/source/engine/godot/bin/godot.linuxbsd.tools.64(_start+0x2e) [0x24ad75e] (??:?)
-- END OF BACKTRACE --
================================================================

@AndreaCatania
Copy link
Contributor

AndreaCatania commented Aug 28, 2021

I spot the issue, the following code playback_node->bus_details.load() is returning a nullptr:

		if (playback_node->bus_details.load()->bus_active[bus_idx]) {
			map[playback_node->bus_details.load()->bus[bus_idx]] = p_volumes;
		}

so it crashes. To fix it we should check it in this way:

	if (playback_node->bus_details.load()) {
		if (playback_node->bus_details.load()->bus_active[bus_idx]) {
			map[playback_node->bus_details.load()->bus[bus_idx]] = p_volumes;
		}
	}

Even if this code fix the issue momentarily on my project, it still unsafe. Atomic pointers, if not under some well documented and strict conditions, are unsafe since the check is guaranteed only the moment of the first if; any subsequent call may cause a segmentation fault, since the pointer can suddenly change.
To fix this issue, we need to use a mutex, or make sure that by design the pointer can't ever change at that point.

Please let me know if you need that I open an issue, I'm also available to test an eventual fix.

@ellenhp
Copy link
Contributor Author

ellenhp commented Aug 28, 2021

Good catch. This can be easily made to be safe, it was just an oversight on my part.

@ellenhp
Copy link
Contributor Author

ellenhp commented Aug 28, 2021

Maybe not as easily as I had hoped. Would you mind making an issue because this might need further discussion and I'd like to be able to point to something other than an enormous PR when I submit a fix.

@ellenhp
Copy link
Contributor Author

ellenhp commented Aug 28, 2021

@AndreaCatania would you mind testing #52187?

@ellenhp ellenhp changed the title Move mixing out of the AudioStreamPlayback* nodes Move mixing out of the AudioStreamPlayer* nodes Sep 10, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
6 participants