Custom audio streams: PCM and synthesis
Working C# generator, buffering, resampling and the native contract.
AudioStream describes sound; AudioStreamPlayback holds one playback instance’s state. Several players can share a resource, so independent voices must not unconditionally keep their cursor, phase and decoder state in that shared resource.
Choose the implementation level
| Task | Approach |
|---|---|
| Play WAV, Ogg or MP3 | An existing AudioStream and AudioStreamPlayer; no custom decoder needed |
| Synthesize sound or supply ready PCM frames | AudioStreamGenerator and its playback buffer |
| Add a native decoder or a special data source | Custom AudioStream/AudioStreamPlayback through the appropriate native API |
| Process already-mixed bus audio | AudioEffect rather than an AudioStream resource |
Complete C# streaming PCM example
Create SineStream.cs in a C# project using Reckfell SDK 1.0.0 and attach it to an Entity in the level you run. In an ordinary run, it generates a quiet 220 Hz stereo tone while the Entity is in the tree. Bus must name an existing bus; Master is the default.
using System;
using Reckfell;
public partial class SineStream : Entity
{
private const int SourceRate = 48000;
private readonly Vector2[] _block = new Vector2[256];
private AudioStreamGenerator? _stream;
private AudioStreamPlayer? _player;
private AudioStreamGeneratorPlayback? _playback;
private double _phase;
[Export] public StringName Bus { get; set; } = "Master";
public int UnderrunCount => _playback?.GetSkips() ?? 0;
public override void _Ready()
{
_stream = new AudioStreamGenerator
{
MixRateMode = AudioStreamGenerator.AudioStreamGeneratorMixRate.Custom,
MixRate = SourceRate,
BufferLength = 0.15f,
};
_player = new AudioStreamPlayer
{
Stream = _stream,
Bus = Bus,
PlaybackType = AudioServer.PlaybackType.Stream,
};
AddChild(_player);
_player.Play();
_playback = _player.GetStreamPlayback() as AudioStreamGeneratorPlayback;
FillQueue();
}
public override void _Process(double delta) => FillQueue();
public static void FillBlock(Span<Vector2> frames, ref double phase)
{
for (int i = 0; i < frames.Length; i++)
{
float sample = (float)(0.1 * Math.Sin(Math.Tau * phase));
frames[i] = new Vector2(sample, sample);
phase += 220.0 / SourceRate;
phase -= Math.Floor(phase);
}
}
private void FillQueue()
{
if (_playback is null)
return;
int available = _playback.GetFramesAvailable();
while (available > 0)
{
int count = Math.Min(available, _block.Length);
Span<Vector2> frames = _block.AsSpan(0, count);
FillBlock(frames, ref _phase);
if (!_playback.PushBuffer(frames))
return;
available -= count;
}
}
public override void _ExitTree()
{
_player?.Stop();
_player?.Free();
_player = null;
_playback?.Dispose();
_playback = null;
_stream?.Dispose();
_stream = null;
}
}Here PCM uses normalized float values: Vector2 contains the left and right channels of one frame. A frame is neither a single channel nor a byte. Both channels receive the same signal at amplitude 0.1; the phase increment is calculated for 48000 source frames per second.
MixRateMode is explicitly Custom: only in that mode does MixRate select the source rate. Output/Input modes use the corresponding AudioServer rate; this does not enable microphone input. Device output rate can differ from source rate, and the engine resamples. Do not assume every system always runs at 44100 Hz.
Buffering, phase and underruns
GetFramesAvailable returns writable space, not queued audio. Fill only that space. PushBuffer returns false when the entire block does not fit; it does not partially accept that block. The example has one producer and reuses its array; do not write to the same playback from several independent producers.
Phase persists across blocks, so the waveform does not restart at every boundary. _Process refills the queue, but delta does not determine pitch: source frame rate and phase increment do.
BufferLength requests a buffer duration when playback is created. Internal ring-buffer sizing rounds its actual capacity; it is not exact input-to-speaker latency. A larger queue tolerates longer refill delays but can increase control latency. Do not change the running example’s rate or buffer length; stop it and create a consistently configured stream again.
When PCM runs out, the mixer fills missing frames with zeros and increments GetSkips. The example’s UnderrunCount exposes that counter: it counts underrun events, not lost frames. Playback start resets it. Check its growth on target devices under load. ClearBuffer is allowed only on inactive playback; it is not a safe rewind operation while running. Generator Seek does not implement a time-position jump.
Diagnose failures
| Symptom | Check |
|---|---|
| Silence | Entity is in the tree, player started, Bus exists, bus is unmuted, device is available |
| Crackling or gaps | Rising UnderrunCount, main-thread stalls, insufficient queued PCM |
| Wrong pitch/speed | Source rate, MixRateMode and phase increment agree |
| Clipping | PCM amplitude and the sum of voices/effects on the bus |
| Delayed control response | Queue duration plus mixer, driver and device latency |
This example demonstrates continuous queue filling. It does not implement fade-in/out, musical timing, microphone input, a network jitter buffer or guarantees under arbitrary load. For a smooth stop, add an amplitude envelope and let it play out instead of abruptly removing the Entity.
Native decoder contract
In current C++ headers, the resource overrides instantiate_playback(), returning Ref<AudioStreamPlayback>. Each new playback holds its own cursor and decoder state. Duration is described by double get_length() const.
Direct mixing uses int mix(AudioFrame *p_buffer, float p_rate_scale, int p_frames): fill output stereo frames and return the number actually mixed. This is neither the old void callback nor a PCM16 pointer. AudioStreamPlaybackResampled uses int _mix_internal(AudioFrame *p_buffer, int p_frames) internally, while get_stream_sampling_rate() reports the source rate.
Do not perform blocking file/network access, unbounded heavy decoding, logging or per-block allocation in the audio thread. Prepare buffers and data ahead of time and transfer them with controlled synchronization. File loading and format registration are a separate task covered by custom resource formats.
These native signatures were checked against source. The executable example above uses the existing AudioStreamGenerator; it does not implement a new native decoder.
Reference: AudioStreamGenerator, AudioStreamGeneratorPlayback, AudioStreamPlayback, AudioServer.