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 ![]()
--!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