Skip to content

Tutorials - Voice

In case you want to Steam's Voice functionality in your game, we might as well cover that too! This example is based partialy on this Github repo for networked voice chat in Godot and Valve's SpaceWar example. There are additional ideas, details, and such from users Punny and ynot01.

Relevant GodotSteam classes and functions

Preparations

First we will set up a bunch of variables that will get used later on.

var current_sample_rate: int = 48000
var has_loopback: bool = false
var local_playback: AudioStreamGeneratorPlayback = null
var local_voice_buffer: PoolByteArray = PoolByteArray()
var network_playback: AudioStreamGeneratorPlayback = null
var network_voice_buffer: PoolByteArray = PoolByteArray()
var packet_read_limit: int = 5
var current_sample_rate: int = 48000
var has_loopback: bool = false
var local_playback: AudioStreamGeneratorPlayback = null
var local_voice_buffer: PoolByteArray = PackedByteArray()
var network_playback: AudioStreamGeneratorPlayback = null
var network_voice_buffer: PoolByteArray = PackedByteArray()
var packet_read_limit: int = 5

A few quick things to mention, has_loopback will toggle whether or not you can hear yourself in voice chat. While not great in-game, it is handy when testing. You also notice local_ / network_ prefixed variables; these are to house data on your end and someone else's. In theory, the network_ will cover all audio output from the network / Steam (anything that isn't you).

Also, somewhere in the root of our test, we need to create two AudioStreamPlayer nodes. One named Local and one named Network which we can use as $Local and $Network respectively.

In order to add the voice data to the audio stream, we need to set up the AudioStreamGeneratorPlayback. Make sure the AudioStreamPlayer nodes both have and AudioStreamGenerator as their streams. Set the stream's buffer_length to something like 0.1:

func _ready() -> void:
    $Local.stream.mix_rate = current_sample_rate
    $Local.play()
    local_playback = $Local.get_stream_playback()

    $Network.stream.mix_rate = current_sample_rate
    $Network.play()
    network_playback = $Network.get_stream_playback()

Getting Voice Data

Also, in our _process() function we will add the call to our check_for_voice() function:

func _process(_delta: float) -> void:
    check_for_voice()

This function basically just looks to see if there is voice data from Steam available then gets and sends it off to be processed.

func check_for_voice() -> void:
    var available_voice: Dictionary = Steam.getAvailableVoice()

    # Seems there is voice data
    if available_voice['result'] == Steam.VOICE_RESULT_OK and available_voice['buffer'] > 0:
        # Valve's getVoice uses 1024 but GodotSteam's is set at 8192?
        # Our sizes might be way off; internal GodotSteam notes that Valve suggests 8kb
        # However, this is not mentioned in the header nor the SpaceWar example but -is- in Valve's docs which are usually wrong
        var voice_data: Dictionary = Steam.getVoice()
        if voice_data['result'] == Steam.VOICE_RESULT_OK and voice_data['written']:
            print("Voice message has data: %s / %s" % [voice_data['result'], voice_data['written']])

            # Here we can pass this voice data off on the network
            Networking.send_message(voice_data['buffer'])

            # If loopback is enable, play it back at this point
            if has_loopback:
                print("Loopback on")
                process_voice_data(voice_data, "local")

Our Networking.send_message function can be whatever P2P networking function you have for sending packets / data. At the time of writing this, I wonder how necessary this is since Steam is recording voice data and we are checking if there is any available; surely it knows who we are talking to, if anyone. Do we need to send this data back as a packet?

Processing Voice Data

OK, now that we have something, let's hear it. We may want to use the optimal sample rate instead of whatever we set in our current_sample_rate variable. In which case we can use this function:

func get_sample_rate() -> void:
    current_sample_rate = Steam.getVoiceOptimalSampleRate()
    print("Current sample rate: %s" % current_sample_rate)

This function can be attached to a button. We can also add a toggle to this button to change between the optimal rate or back to our default. When changing the sample rate, remember to change the AudioStreamGenerator's mix rate too:

func get_sample_rate(is_toggled: bool) -> void:
    if is_toggled:
        current_sample_rate = Steam.getVoiceOptimalSampleRate()
    else:
        current_sample_rate = 48000
    $Local.stream.mix_rate = current_sample_rate
    $Network.stream.mix_rate = current_sample_rate
    print("Current sample rate: %s" % current_sample_rate)

We have our sample rates figured out so let's try to actual play this data. Since we are just testing things, we will use the local_ variables and nodes.

func process_voice_data(voice_data: Dictionary, voice_source: String) -> void:
    # Our sample rate function above without toggling
    get_sample_rate()

    var decompressed_voice: Dictionary = Steam.decompressVoice(voice_data['buffer'], voice_data['written'], current_sample_rate)

    if decompressed_voice['result'] == Steam.VOICE_RESULT_OK and decompressed_voice['size'] > 0:
        print("Decompressed voice: %s" % decompressed_voice['size'])

        if voice_source == "local":
            local_voice_buffer = decompressed_voice['uncompressed']
            local_voice_buffer.resize(decompressed_voice['size'])

            # We now create an audio stream to put our data into
            var local_audio: AudioStreamSample = AudioStreamSample.new()
            local_audio.mix_rate = current_sample_rate
            local_audio.data = local_voice_buffer
            local_audio.format = AudioStreamSample.FORMAT_16_BITS

            # Lastly, put this into a node and play it
            $Local.stream = local_audio
            $Local.play()
func process_voice_data(voice_data: Dictionary, voice_source: String) -> void:
    # Our sample rate function above without toggling
    get_sample_rate()

    var decompressed_voice: Dictionary = Steam.decompressVoice(voice_data['buffer'], voice_data['written'], current_sample_rate)

    if decompressed_voice['result'] == Steam.VOICE_RESULT_OK and decompressed_voice['size'] > 0:
        print("Decompressed voice: %s" % decompressed_voice['size'])

        if voice_source == "local":
            local_voice_buffer = decompressed_voice['uncompressed']
            local_voice_buffer.resize(decompressed_voice['size'])

            # We now iterate through the local_voice_buffer and push the samples to the audio generator
            for i: int in range(0, mini(local_playback.get_frames_available() * 2, local_voice_buffer.size()), 2):
                # Steam's audio data is represented as 16-bit single channel PCM audio, so we need to convert it to amplitudes
                # Combine the low and high bits to get full 16-bit value
                var raw_value: int = LOCAL_VOICE_BUFFER[0] | (LOCAL_VOICE_BUFFER[1] << 8)
                # Make it a 16-bit signed integer
                raw_value = (raw_value + 32768) & 0xffff
                # Convert the 16-bit integer to a float on from -1 to 1
                var amplitude: float = float(raw_value - 32768) / 32768.0

                # push_frame() takes a Vector2. The x represents the left channel and the y represents the right channel
                local_playback.push_frame(Vector2(amplitude, amplitude))

                # Delete the used samples
                local_voice_buffer.remove_at(0)
                local_voice_buffer.remove_at(0)

Recording Voice

So how do we actually get our voice data to Steam? We will need to set up a button that can be toggled and attached to the following function:

func record_voice(is_recording: bool) -> void:
    # If talking, suppress all other audio or voice comms from the Steam UI
    Steam.setInGameVoiceSpeaking(steam_id, is_recording)

    if is_recording:
        Steam.startVoiceRecording()
    else:
        Steam.stopVoiceRecording()

Easy-peasy! You will notice our setInGameVoiceSpeaking has a note that it is used to suppress any sounds or whatnot coming from the Steam UI; you'll definitely want to have that.

You may want to provide the option for always-on voice chat, in which case you'd probably want to fire this function once in your _ready() or somewhere else to start recording until the player turns it off.


And that's the basics of Steam Voice chat. Again, there is a weird choppiness to the playback in this example but surely we can iron that out at some point.

Additional Resources

'Godot VOIP' by ikbencasdoei

Example Project

To see this tutorial in action, check out our GodotSteam Example Project on GitHub. There you can get a full view of the code used which can serve as a starting point for you to branch out from.