Audio recording: microphone, PCM, and WAV
Collect a bounded recording, detect discarded frames, and save WAV.
Recording needs three separate parts: an audio source, a bus with PCM capture, and result storage. Enabling audio input does not itself create a WAV file. This example records from its own bus with a duration limit and no direct microphone monitoring.
Test the path without a microphone first
Use DemoTone.cs as the source. This isolates routing, capture, and saving. Add microphone input afterward so that a device-permission failure is not mistaken for a WAV error.
The class creates its own muted bus with AudioEffectCapture. It collects stereo frames on the main thread, limits duration, and refuses a result after discarded frames. Dispose() removes only its owned bus.
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using Reckfell;
public sealed class PcmBusRecording : IDisposable
{
private readonly AudioEffectCapture _capture = new() { BufferLength = 1 };
private readonly List<Vector2> _frames = new();
private readonly int _rate, _limit;
private long _discardedAtStart;
private bool _disposed, _failed;
public string BusName { get; } = "Recording_" + Guid.NewGuid().ToString("N");
public bool IsRecording { get; private set; }
public int FrameCount => _frames.Count;
public PcmBusRecording(float maxSeconds = 10)
{
if (!float.IsFinite(maxSeconds) || maxSeconds <= 0 || maxSeconds > 30)
throw new ArgumentOutOfRangeException(nameof(maxSeconds));
_rate = (int)AudioServer.GetMixRate(); _limit = (int)(_rate * maxSeconds);
AudioServer.AddBus(); int bus = AudioServer.BusCount - 1;
AudioServer.SetBusName(bus, BusName);
AudioServer.AddBusEffect(bus, _capture);
AudioServer.SetBusMute(bus, true); // Capture before mute; no live monitoring.
}
public void Begin()
{
ObjectDisposedException.ThrowIf(_disposed, this);
_frames.Clear(); _capture.ClearBuffer();
_discardedAtStart = _capture.GetDiscardedFrames();
_failed = false; IsRecording = true;
}
public void Poll()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!IsRecording) return;
if (_capture.GetDiscardedFrames() != _discardedAtStart)
{
_failed = true; IsRecording = false;
throw new InvalidOperationException("Audio frames were lost; discard this recording.");
}
int count = _capture.GetFramesAvailable();
if (count == 0) return;
var frames = _capture.GetBuffer(count);
if (_capture.GetDiscardedFrames() != _discardedAtStart)
{
_failed = true; IsRecording = false;
throw new InvalidOperationException("Audio frames were lost; discard this recording.");
}
int keep = Math.Min(frames.Length, _limit - _frames.Count);
for (int i = 0; i < keep; i++) _frames.Add(frames[i]);
if (_frames.Count == _limit) IsRecording = false;
}
public AudioStreamWav? Finish()
{
Poll(); IsRecording = false;
if (_failed)
throw new InvalidOperationException("Audio frames were lost; discard this recording.");
if (_frames.Count == 0) return null;
byte[] pcm = new byte[_frames.Count * 4];
for (int i = 0; i < _frames.Count; i++)
{
Vector2 frame = _frames[i].IsFinite() ? _frames[i] : Vector2.Zero;
BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(i * 4, 2), (short)(Math.Clamp(frame.X, -1, 1) * short.MaxValue));
BinaryPrimitives.WriteInt16LittleEndian(pcm.AsSpan(i * 4 + 2, 2), (short)(Math.Clamp(frame.Y, -1, 1) * short.MaxValue));
}
return new AudioStreamWav { Format = AudioStreamWav.FormatEnum.Format16Bits, Stereo = true, MixRate = _rate, Data = pcm };
}
public void Dispose()
{
if (_disposed) return;
IsRecording = false;
int bus = AudioServer.GetBusIndex(BusName);
if (bus > 0) AudioServer.RemoveBus(bus);
_capture.Dispose(); _frames.Clear(); _disposed = true;
}
}Connect to your UI
- Create
PcmBusRecordingonce and anAudioStreamPlayeras a level child. AssignBus = recording.BusNameandPlaybackType = AudioServer.PlaybackType.Streamto the player. - For testing, use a stream from
DemoTone.Create(). On a user command, callrecording.Begin(), thenplayer.Play(). - Call
Poll()every frame while recording. Capture is not an infinite queue: leaving data unread can lose frames. This example reports loss rather than silently producing damaged audio. - Stop with
player.Stop()first, thenrecording.Finish(). An empty result isnull, not a usable recording. - For a nonempty
AudioStreamWav, callSaveToWav("user://recording.wav")and checkError.Ok. Dispose the returned resource when finished. - Stop the player before disposing the recorder when leaving the screen. Removing its bus must not leave a running microphone that could be rerouted to the main output.
The default cap is 10 seconds; up to 30 is allowed. Reaching the cap stops growth and preserves the collected clip even if retrieved later. Begin() clears the previous take. The result uses mixer sample rate and stereo PCM16: a frame contains two channels and occupies 4 bytes.
The cap stops frame collection, not the source. Stop AudioStreamPlayer in your code as soon as IsRecording becomes false. This class does not own the player or automatically disable physical audio input.
Connect microphone input
Enable audio/driver/enable_input in project settings, check AudioServer.InputDevice, and check OS/export permissions. Assign new AudioStreamMicrophone() to the player while retaining the same bus. Begin reading on an explicit user command and show recording state plus a stop control.
Bus mute does not prevent the preceding capture, as verified with a synthetic tone. It prevents direct output monitoring; do not unmute without a deliberate monitoring/headphone setup. All sources routed to the bus are captured, not just the latest player.
AudioEffectRecord or AudioEffectCapture
AudioEffectRecord can capture a bus signal with a simple recording workflow; its result type is AudioStreamWav, not AudioStreamSample. Add the effect to a bus before enabling recording. Restarting begins a new take. Do not unconditionally call GetRecording() without data or treat an active flag as proof of PCM.
This example uses AudioEffectCapture to control reads, memory/duration limits, and discarded frames explicitly. ClearBuffer() clears queued data but does not reset accumulated GetDiscardedFrames(), so each take records its own counter baseline.
Diagnose failures
No data: check Play(), stream, bus, polling, and input permission. File contains silence: inspect the real source and levels; a WAV alone does not prove microphone functionality. Discarded-frame refusal: reduce main-thread stalls, read more frequently, or design a dedicated streaming path. Save failure: check the error code and user:// access; do not save into exported game resources.
Checks cover a Dummy-driver synthetic tone, muted-bus capture, PCM16, WAV save/reload, duration cap, delayed retrieval, overflow refusal, and cleanup. Physical microphone input, OS permissions, listening quality, hardware latency, and exports were not tested.