How to get audio grains for granular synthesis

If you’ve ever tried to mess with granular audio, you’ve probably noticed there’s no built-in way to split an audio track into “grains” (short, clean audio segments). To work around that for my engine sounds, I wrote a bit of code that uses the Audio API’s GetWaveformAsync function to fetch waveform data, then slice the audio at zero crossings and add the resulting grains to a table. Here’s the code in case anyone else is experimenting with granular synthesis or just wants to chop up audio cleanly.

Happy coding :partying_face:

--!strict

type configType = {
	ASSET_ID: string,
	RPM1: number,
	RPM2: number,
	ELEMENTS: number,
	SAMPLES: number,
	SAMPLE_RATE: number
}

local EXAMPLE_CONFIG_A = {
	["ASSET_ID"] = "rbxassetid://123456789123456",
	["RPM1"] = 1129.6,
	["RPM2"] = 8726.1,
	["ELEMENTS"] = 326,
	["SAMPLES"] = 439770,
	["SAMPLE_RATE"] = 48000
} :: configType

local function findNearestZeroCrossing(waveform, centerIdx: number, window: number)
	local minAbs = math.abs(waveform[centerIdx] or 1e9)
	local foundCrossing = false
	local bestIdx = centerIdx

	-- First pass: look for a true zero crossing
	for offset = -window, window do
		local i = centerIdx + offset
		if i > 0 and i < #waveform then
			if waveform[i] * waveform[i + 1] < 0 then
				return i
			end
		end
	end

	-- Second pass: find the sample closest to zero
	for offset = -window, window do
		local i = centerIdx + offset
		if i > 0 and i <= #waveform then
			local absVal = math.abs(waveform[i])
			if absVal < minAbs then
				minAbs = absVal
				bestIdx = i
			end
		end
	end

	return bestIdx
end

local function generateGrains(player: AudioPlayer, config: configType)
	local totalLength = player.TimeLength
	local sampleRate = config.SAMPLE_RATE
	local samples = config.SAMPLES
	local elements = config.ELEMENTS

	local waveform = player:GetWaveformAsync(NumberRange.new(0, totalLength), samples) -- The magic
	local grains = {}

	local samplesPerGrain = math.floor(samples / elements)
	local window = math.max(1, math.floor(samplesPerGrain / 4))
	local bigWindow = math.max(1, math.floor(samplesPerGrain / 2))

	for i = 0, elements - 1 do
		local idealStart = i * samplesPerGrain + 1
		local idealEnd = (i + 1) * samplesPerGrain

		-- Bigger window at edges to avoid bad cuts
		local useWindow = (i == 0 or i == elements - 1) and bigWindow or window

		-- Start / end at zero crossings for clean grains
		local startIdx = findNearestZeroCrossing(waveform, idealStart, useWindow)
		local endIdx = findNearestZeroCrossing(waveform, idealEnd, useWindow)

		-- Ensure grain isn't empty or reversed
		if endIdx <= startIdx then
			endIdx = math.min(startIdx + 1, #waveform)
		end

		-- Convert sample index to time for playback
		local startTime = (startIdx - 1) / sampleRate
		local endTime = (endIdx - 1) / sampleRate

		-- Fetch RPM for grain with interpolation
		local t = i / (elements - 1)
		local rpm = config.RPM1 + (config.RPM2 - config.RPM1) * t

		-- Fetch debug values
		local startValue = waveform[startIdx] or 0
		local endValue = waveform[endIdx] or 0

		table.insert(grains, {
			startTime = startTime,
			endTime = endTime,
			rpm = rpm,
			startIdx = startIdx,
			endIdx = endIdx,
			startValue = startValue,
			endValue = endValue,
		})
	end

	return grains
end
9 Likes

Very nice!, even though i mostly have no idea what im looking at.

I was wondering, can this script be modified so you can input the ID into the function and not the config table? Impretty sure it would be fine just checking

The ID has to be loaded on an AudioPlayer for GetWaveformAsync to be usable and the config table is necessary unless you want to pass a bunch of arguments separately.

Okay I see, Thank you for clarifying!

Ill probably be using this for my own resource that is sound related. Ill credit you. Is it okay if I use it?

1 Like

Its a learning resource, no need to credit, good luck and have fun soldier.