creates video voice memos from audio clips; with bluesky integration. trill.ptr.pet
1import { 2 Output as MediaOutput, 3 Mp4OutputFormat, 4 BufferTarget, 5 CanvasSource, 6 QUALITY_MEDIUM, 7 getFirstEncodableVideoCodec, 8 Input as MediaInput, 9 BlobSource, 10 ALL_FORMATS, 11 Conversion, 12} from "mediabunny"; 13 14const renderCanvas = new OffscreenCanvas(1280, 720); 15 16// claude generated visualizer code cuz im lazy and it works okay ig 17 18/** 19 * Extracts frequency data from audio file for visualization 20 * Efficient FFT-like approach with adaptive sensitivity based on track volume 21 */ 22const extractFrequencyData = async (file: File, fps: number = 30) => { 23 if (fps <= 0) 24 throw new Error("invalid frame rate: must be greater than zero"); 25 26 const audioContext = new AudioContext(); 27 const arrayBuffer = await file.arrayBuffer(); 28 const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); 29 30 const sampleRate = audioBuffer.sampleRate; 31 const channelData = audioBuffer.getChannelData(0); 32 const fftSize = 2048; 33 const frequencyBinCount = fftSize / 2; 34 const hopSize = Math.floor(sampleRate / fps); 35 const totalFrames = Math.floor(channelData.length / hopSize); 36 37 // Pre-calculate volume levels for adaptive scaling 38 const volumeWindowSize = Math.floor(sampleRate * 2); // 2 second windows 39 const volumeLevels: number[] = []; 40 41 for (let i = 0; i < channelData.length; i += volumeWindowSize) { 42 let sum = 0; 43 const end = Math.min(i + volumeWindowSize, channelData.length); 44 for (let j = i; j < end; j++) { 45 sum += Math.abs(channelData[j]); 46 } 47 volumeLevels.push(sum / (end - i)); 48 } 49 50 // Calculate overall average and max volume for normalization 51 const avgVolume = 52 volumeLevels.reduce((a, b) => a + b, 0) / volumeLevels.length; 53 const maxVolume = Math.max(...volumeLevels); 54 55 const allFrequencyData: Uint8Array[] = []; 56 let previousFrame: Float32Array | null = null; 57 const smoothingFactor = 0.45; 58 59 // Pre-compute sine/cosine tables for efficiency 60 const numBands = 128; 61 const cosTable: number[][] = []; 62 const sinTable: number[][] = []; 63 64 for (let i = 0; i < numBands; i++) { 65 cosTable[i] = []; 66 sinTable[i] = []; 67 const freqIndex = Math.pow(i / numBands, 1.5) * frequencyBinCount; 68 69 for (let j = 0; j < fftSize; j++) { 70 const angle = (2 * Math.PI * freqIndex * j) / fftSize; 71 cosTable[i][j] = Math.cos(angle); 72 sinTable[i][j] = Math.sin(angle); 73 } 74 } 75 76 for (let frame = 0; frame < totalFrames; frame++) { 77 const offset = frame * hopSize; 78 79 // Determine which volume window this frame is in 80 const volumeIndex = Math.floor(offset / volumeWindowSize); 81 const currentVolume = 82 volumeLevels[Math.min(volumeIndex, volumeLevels.length - 1)]; 83 84 // Calculate adaptive boost: quieter sections get more boost 85 // Range from 1.5x to 8x boost depending on how quiet the section is 86 const volumeRatio = currentVolume / (avgVolume + 0.0001); 87 const adaptiveBoost = Math.pow(1 / (volumeRatio + 0.3), 0.6) * 4; 88 89 const slice = new Float32Array(fftSize); 90 91 // Apply Hann window 92 for (let i = 0; i < fftSize; i++) { 93 const idx = offset + i; 94 const sample = idx < channelData.length ? channelData[idx] : 0; 95 const window = 0.5 * (1 - Math.cos((2 * Math.PI * i) / fftSize)); 96 slice[i] = sample * window; 97 } 98 99 // Calculate magnitudes for reduced frequency bands 100 const frequencyData = new Float32Array(numBands); 101 102 for (let i = 0; i < numBands; i++) { 103 let real = 0; 104 let imag = 0; 105 106 for (let j = 0; j < fftSize; j += 4) { 107 real += slice[j] * cosTable[i][j]; 108 imag += slice[j] * sinTable[i][j]; 109 } 110 111 const magnitude = Math.sqrt(real * real + imag * imag); 112 113 // Apply logarithmic scaling with adaptive boost 114 const boosted = Math.log(magnitude * 30 * adaptiveBoost + 1) * 20; 115 frequencyData[i] = boosted; 116 } 117 118 // Temporal smoothing 119 if (previousFrame) { 120 for (let i = 0; i < numBands; i++) { 121 frequencyData[i] = 122 smoothingFactor * previousFrame[i] + 123 (1 - smoothingFactor) * frequencyData[i]; 124 } 125 } 126 127 // Spatial smoothing 128 const smoothed = new Float32Array(numBands); 129 for (let i = 0; i < numBands; i++) { 130 const prev = i > 0 ? frequencyData[i - 1] : frequencyData[i]; 131 const next = i < numBands - 1 ? frequencyData[i + 1] : frequencyData[i]; 132 smoothed[i] = (prev + frequencyData[i] * 2 + next) / 4; 133 } 134 135 // Expand back to full frequencyBinCount for circular visualization 136 const uint8Data = new Uint8Array(frequencyBinCount); 137 for (let i = 0; i < frequencyBinCount; i++) { 138 const bandIndex = (i / frequencyBinCount) * numBands; 139 const lower = Math.floor(bandIndex); 140 const upper = Math.min(numBands - 1, Math.ceil(bandIndex)); 141 const t = bandIndex - lower; 142 143 const value = smoothed[lower] * (1 - t) + smoothed[upper] * t; 144 uint8Data[i] = Math.min(255, Math.max(0, Math.floor(value))); 145 } 146 147 allFrequencyData.push(uint8Data); 148 previousFrame = smoothed; 149 } 150 151 return allFrequencyData; 152}; 153 154const drawPfp = ( 155 ctx: OffscreenCanvasRenderingContext2D, 156 pfpImg: HTMLImageElement, 157 centerX: number, 158 centerY: number, 159 baseRadius: number, 160) => { 161 const pfpSize = baseRadius * 1.9; 162 const pfpX = centerX - pfpSize / 2; 163 const pfpY = centerY - pfpSize / 2; 164 165 ctx.save(); 166 ctx.beginPath(); 167 ctx.arc(centerX, centerY, pfpSize / 2, 0, Math.PI * 2); 168 ctx.closePath(); 169 ctx.clip(); 170 ctx.drawImage(pfpImg, pfpX, pfpY, pfpSize, pfpSize); 171 ctx.restore(); 172}; 173 174/** 175 * Draws circular audio visualizer around center 176 * Inspired by circular spectrum visualizers: https://codepen.io/nfj525/pen/rVBaab 177 */ 178const drawCircularVisualizer = ( 179 ctx: OffscreenCanvasRenderingContext2D, 180 canvas: OffscreenCanvas, 181 frequencyData: Uint8Array, 182 pfpImg: HTMLImageElement | null, 183) => { 184 const centerX = canvas.width / 2; 185 const centerY = canvas.height / 2; 186 const baseRadius = Math.min(canvas.width, canvas.height) * 0.15; 187 const maxBarHeight = baseRadius * 1.5; 188 189 // Draw profile picture in center if provided 190 if (pfpImg) drawPfp(ctx, pfpImg, centerX, centerY, baseRadius); 191 192 // Draw circular bars 193 const barCount = 128; 194 const angleStep = (Math.PI * 2) / barCount; 195 196 for (let i = 0; i < barCount; i++) { 197 const dataIndex = Math.floor((i / barCount) * frequencyData.length); 198 const value = frequencyData[dataIndex] / 255; 199 const barHeight = value * maxBarHeight; 200 201 const angle = i * angleStep - Math.PI / 2; 202 const innerX = centerX + Math.cos(angle) * baseRadius; 203 const innerY = centerY + Math.sin(angle) * baseRadius; 204 const outerX = centerX + Math.cos(angle) * (baseRadius + barHeight); 205 const outerY = centerY + Math.sin(angle) * (baseRadius + barHeight); 206 207 const hue = (i / barCount) * 360; 208 const brightness = 50 + value * 50; 209 ctx.strokeStyle = `hsl(${hue}, 100%, ${brightness}%)`; 210 ctx.lineWidth = 3; 211 212 ctx.beginPath(); 213 ctx.moveTo(innerX, innerY); 214 ctx.lineTo(outerX, outerY); 215 ctx.stroke(); 216 } 217}; 218 219/** 220 * Draws flat horizontal audio visualizer bars 221 */ 222const drawFlatVisualizer = ( 223 ctx: OffscreenCanvasRenderingContext2D, 224 canvas: OffscreenCanvas, 225 frequencyData: Uint8Array, 226) => { 227 const barCount = 64; 228 const barWidth = canvas.width / barCount; 229 const maxBarHeight = canvas.height * 0.8; 230 const baseY = canvas.height / 2; 231 232 for (let i = 0; i < barCount; i++) { 233 const dataIndex = Math.floor((i / barCount) * frequencyData.length); 234 const value = frequencyData[dataIndex] / 255; 235 const barHeight = (value * maxBarHeight) / 2; 236 237 const hue = (i / barCount) * 360; 238 const brightness = 50 + value * 50; 239 ctx.fillStyle = `hsl(${hue}, 100%, ${brightness}%)`; 240 241 // Draw mirrored bars (top and bottom) 242 ctx.fillRect(i * barWidth, baseY - barHeight, barWidth - 2, barHeight); 243 ctx.fillRect(i * barWidth, baseY, barWidth - 2, barHeight); 244 } 245}; 246 247type RenderOptions = { 248 pfpUrl: string | undefined; 249 visualizer: boolean; 250 frameRate: number; 251 bgColor: string; 252 duration?: number; 253}; 254 255export const render = async (file: File, opts: RenderOptions) => { 256 // load pfp picture 257 let pfpImg: HTMLImageElement | null = null; 258 if (opts.pfpUrl) { 259 pfpImg = new Image(); 260 pfpImg.crossOrigin = "anonymous"; 261 await new Promise((resolve, reject) => { 262 pfpImg!.onload = resolve; 263 pfpImg!.onerror = reject; 264 pfpImg!.src = opts.pfpUrl!; 265 }); 266 } 267 268 const input = new MediaInput({ 269 source: new BlobSource(file), 270 formats: ALL_FORMATS, 271 }); 272 273 const audioTrack = await input.getPrimaryAudioTrack(); 274 if (!audioTrack) throw "no audio track found."; 275 276 if (!(await audioTrack.canDecode())) 277 throw "audio track cannot be decoded by browser."; 278 279 const duration = opts.duration ?? (await audioTrack.computeDuration()); 280 if (!duration) throw "couldn't get audio duration."; 281 282 const videoCodec = await getFirstEncodableVideoCodec( 283 new Mp4OutputFormat().getSupportedVideoCodecs(), 284 { 285 width: renderCanvas.width, 286 height: renderCanvas.height, 287 }, 288 ); 289 if (!videoCodec) throw "your browser doesn't support video encoding."; 290 291 const ctx = renderCanvas.getContext("2d"); 292 if (!ctx) throw "couldn't get canvas context."; 293 294 const output = new MediaOutput({ 295 format: new Mp4OutputFormat({ 296 fastStart: "in-memory", 297 }), 298 target: new BufferTarget(), 299 }); 300 const conversion = await Conversion.init({ 301 input, 302 output, 303 }); 304 const videoSource = new CanvasSource(renderCanvas, { 305 codec: "avc", 306 bitrate: QUALITY_MEDIUM, 307 }); 308 output.addVideoTrack(videoSource); 309 await output.start(); 310 311 const bgColor = opts.bgColor; 312 const drawBackground = () => { 313 ctx.fillStyle = bgColor; 314 ctx.fillRect(0, 0, renderCanvas.width, renderCanvas.height); 315 }; 316 317 if (opts.visualizer) { 318 const freqData = await extractFrequencyData(file, opts.frameRate); 319 const frameDuration = 1 / opts.frameRate; 320 321 // Render animated frames 322 for (let i = 0; i < freqData.length; i++) { 323 const timestamp = i * frameDuration; 324 if (timestamp >= duration) break; 325 326 // bg 327 drawBackground(); 328 329 if (pfpImg) 330 drawCircularVisualizer(ctx, renderCanvas, freqData[i], pfpImg); 331 else drawFlatVisualizer(ctx, renderCanvas, freqData[i]); 332 333 await videoSource.add(timestamp); 334 } 335 } else { 336 drawBackground(); 337 338 if (pfpImg) { 339 const centerX = renderCanvas.width / 2; 340 const centerY = renderCanvas.height / 2; 341 const baseRadius = 342 Math.min(renderCanvas.width, renderCanvas.height) * 0.15; 343 344 drawPfp(ctx, pfpImg, centerX, centerY, baseRadius); 345 } 346 347 await videoSource.add(0, duration); 348 } 349 350 videoSource.close(); 351 await conversion.execute(); 352 353 return new Blob([output.target.buffer!], { type: "video/mp4" }); 354};