This example builds a custom generator that supports seeking, and a script that seeks it. The generator plays a 440 Hz tone that fades out over its first 10 seconds of playback, so seeks are audible.
The generator follows the same scheduling rules as an AudioClip generator.
Some of the code in this section requires the following includes:
using Unity.Burst;
using Unity.Collections;
using Unity.IntegerTime;
using UnityEngine;
using UnityEngine.Audio;
using static UnityEngine.Audio.ProcessorInstance;
Make sure to add these includes at the start of your code for them to compile.
The seeks come from a MonoBehaviour on the same GameObject as the AudioSource that plays the generator:
private AudioSource m_AudioSource;
private void Awake()
{
// Expects an AudioSource on the same GameObject.
m_AudioSource = GetComponent<AudioSource>();
}
Get the built-in control context and the generator instance from the audio source. Always guard the handle: the instance may be missing or have been destroyed if the audio source was stopped elsewhere.
// The built-in context controls generator instances created by an AudioSource.
var context = ControlContext.builtIn;
var instance = m_AudioSource.generatorInstance;
// Guard the handle: the instance may be missing or have been destroyed.
if (!context.Exists(instance))
return;
Then send the seeks:
// context is the ControlContext and instance is the generator instance to seek.
// Seek immediately to second 5.
var immediate = new SeekMessage(new DiscreteTime(5.0));
context.SendMessage(instance, ref immediate);
// Schedule a seek: when playback reaches the 10th second exactly, jump back to the start.
var scheduled = new SeekMessage(destination: DiscreteTime.FromTicks(0), when: new DiscreteTime(10.0));
context.SendMessage(instance, ref scheduled);
The control part is a struct that implements GeneratorInstance.IControl<Realtime>. Its Dispose and Update methods stay empty in this example:
struct Control : GeneratorInstance.IControl<Realtime>
{
public void Dispose(ControlContext context, ref Realtime realtime) { }
public void Update(ControlContext context, Pipe pipe) { }
A custom generator ignores SeekMessage until OnMessage handles it. Playback state lives in the realtime part, so OnMessage forwards the seek through the pipe and returns Response.Handled:
public Response OnMessage(ControlContext context, Pipe pipe, Message message)
{
if (message.Is<SeekMessage>())
{
ref var seek = ref message.Get<SeekMessage>();
// Reposition your playback state using seek.destination and seek.when.
// Playback state lives in the realtime part, so this generator hands
// the seek to it through the pipe.
pipe.SendData(context, seek);
return Response.Handled;
}
return Response.Unhandled;
}
In Configure, capture the sample rate. The realtime part needs it to convert seek positions to frames:
public void Configure(
ControlContext context,
ref Realtime realtime,
in AudioFormat format,
out GeneratorInstance.Setup setup,
ref GeneratorInstance.Properties properties)
{
realtime.sampleRate = format.sampleRate;
// Prefer matching the host's format when possible to avoid conversion.
setup = new GeneratorInstance.Setup(format.speakerMode, format.sampleRate);
}
Store pending seeks in frames, so they compare directly against the playback position:
// A SeekMessage converted to frames, queued until playback reaches its position.
// A negative whenFrames marks an immediate seek.
struct SeekCommand
{
public long destinationFrames;
public long whenFrames;
}
The realtime part queues incoming seeks in Update and applies them in Process. A fixed-capacity list keeps the audio thread allocation-free. Process splits the block at a scheduled seek’s position, so the seek fires exactly when playback reaches it:
// A custom generator that supports seeking: a 440 Hz tone that fades out over its
// first 10 seconds of playback, so repositioning the playback is audible. Seeks
// follow the same scheduling rules as AudioClip generators: strict send order,
// applied when playback reaches `when`, dropped when playback has already passed it.
[BurstCompile(CompileSynchronously = true)]
struct Realtime : GeneratorInstance.IRealtime
{
private const float k_TwoPi = 2.0f * Mathf.PI;
private const float k_Frequency = 440.0f; // A4
private const float k_FadeSeconds = 10.0f;
private float phase; // [0, 1)
private long positionFrames; // Playback position; seeks reposition it.
private FixedList128Bytes<SeekCommand> pendingSeeks; // Seeks wait here in send order.
internal float sampleRate; // Hz, set from Configure
// Capabilities must match those reported from IAudioGenerator and IRealtime.
public bool isFinite => false;
public bool isRealtime => false;
public DiscreteTime? length => null;
public void Update(UpdatedDataContext context, Pipe pipe)
{
// Queue the seeks the control part passed down through the pipe, in send order.
foreach (var element in pipe.GetAvailableData(context))
{
if (element.TryGetData(out SeekMessage seek))
{
if (pendingSeeks.Length == pendingSeeks.Capacity)
continue; // The queue is full; drop the seek.
// Convert the content positions to frames. A null `when` seeks immediately.
pendingSeeks.Add(new SeekCommand
{
destinationFrames = (long)((double)seek.destination * sampleRate),
whenFrames = seek.when.HasValue ? (long)((double)seek.when.Value * sampleRate) : -1,
});
}
// Ignore other message types gracefully.
}
}
public GeneratorInstance.Result Process(
in RealtimeContext context,
Pipe pipe,
ChannelBuffer buffer,
GeneratorInstance.Arguments args)
{
float phaseIncrement = k_Frequency / sampleRate;
int frame = 0;
while (frame < buffer.frameCount)
{
int segmentEnd = buffer.frameCount;
// Only look at the seek at the front of the queue: a seek that isn't due
// yet holds back every seek sent after it, including immediate seeks.
if (!pendingSeeks.IsEmpty)
{
var seek = pendingSeeks[0];
if (seek.whenFrames <= positionFrames) // Due now; immediate seeks are negative.
{
pendingSeeks.RemoveAt(0);
// Apply the seek, unless it's a scheduled seek whose position playback
// has already passed - drop that one, it can no longer be reached.
bool alreadyPassed = seek.whenFrames >= 0 && seek.whenFrames < positionFrames;
if (!alreadyPassed)
positionFrames = seek.destinationFrames;
continue;
}
// Not due yet: if `when` lands inside this block, process up to it, so
// the seek applies exactly when playback reaches its position.
long offset = seek.whenFrames - positionFrames;
if (offset < segmentEnd - frame)
segmentEnd = frame + (int)offset;
}
for (; frame < segmentEnd; frame++)
{
// The fade depends on the playback position, so a seek is audible.
float seconds = positionFrames / sampleRate;
float amplitude = Mathf.Clamp01(1.0f - seconds / k_FadeSeconds);
float s = amplitude * Mathf.Sin(phase * k_TwoPi);
for (int ch = 0; ch < buffer.channelCount; ch++)
buffer[ch, frame] = s;
phase += phaseIncrement;
if (phase >= 1.0f) phase -= 1.0f;
positionFrames++;
}
}
return buffer.frameCount;
}
}
Process implements the scheduling rules:
when against the playback position, which jumps on every applied seek, so an earlier seek that jumps backward can make a later seek’s when reachable again.Tie everything together in a MonoBehaviour that implements IAudioGenerator:
public class Driver : MonoBehaviour, IAudioGenerator
{
public bool isFinite => false;
public bool isRealtime => false;
public DiscreteTime? length => null;
public GeneratorInstance CreateInstance(
ControlContext context,
AudioFormat? nestedConfiguration,
CreationParameters creationParameters)
{
// Allocate a new generator instance pairing the realtime and control structs.
return context.AllocateGenerator(new Realtime(), new Control(), nestedConfiguration, creationParameters);
}
}
To try it out:
Driver component to a GameObject with an AudioSource, and assign it to the Generator field in the Inspector.SeekDriver component to the same GameObject.SendSeeks, for example from a UI button. The tone jumps to second 5 and fades from there. When playback reaches second 10, it jumps back to the start and the fade restarts.