Skip to content

Instantly share code, notes, and snippets.

@brianloveswords
Last active December 29, 2022 15:37
Show Gist options
  • Save brianloveswords/3a4575696d0a4e812a0deee308c2945e to your computer and use it in GitHub Desktop.
Save brianloveswords/3a4575696d0a4e812a0deee308c2945e to your computer and use it in GitHub Desktop.
// @ts-check
// I run this in a folder using `caddy file-server` with the two files below
// and an index.html that's more or less just including this file as a script,
// `<script src="/audio.js"></script>`
// prerequisite: two files representing a transition that should be gapless. I
// used the first two tracks off Meshuggah's "Catch Thirtythree" because I
// happen to know that album is intended to be gapless.
const files = ["/t1.flac", "/t2.flac"];
async function main() {
const audioCtx = getAudioCtx();
const getBuffer = (f) => getAudioBuffer({ audioCtx, file: f });
const buffers = await Promise.all(files.map(getBuffer));
// return initialSpike(audioCtx, buffers)
// return loopingBuffer(audioCtx, buffers);
return loopingWriteAfterStart(audioCtx, buffers);
}
/**
* proof: can we can gapless playback at all?
*/
function initialSpike(audioCtx, buffers) {
const buffer = combinedAudioBuffer({ audioCtx, buffers, fill: true });
const source = sourceFromBuffer({ audioCtx, buffer });
// start playback 7 seconds from the transition point.
const offset = buffers[0].duration - 7;
source.start(0, offset);
}
/**
* proof: can we get gapless playback on a buffer that loops?
*/
function loopingBuffer(audioCtx, buffers) {
const reversed = [...buffers].reverse();
const buffer = combinedAudioBuffer({ audioCtx, buffers: reversed, fill: true });
const source = sourceFromBuffer({ audioCtx, buffer, loop: true });
const offset = getTotalDuration(buffers) - 7;
source.start(0, offset);
}
/**
* proof: can we get gapless playback on a buffer that loops when the front of
* the buffer isn't written until after the playback has started?
*/
function loopingWriteAfterStart(audioCtx, buffers) {
const buffer = combinedAudioBuffer({ audioCtx, buffers, fill: false });
const source = sourceFromBuffer({ audioCtx, buffer, loop: true });
const first = buffers[0];
const second = buffers[1];
// write the first track in the second half of the buffer
copyBuffer({ src: first, dest: buffer, position: second.length });
const offset = getTotalDuration(buffers) - 7;
source.start(0, offset);
setTimeout(() => {
console.log("writing front of buffer");
copyBuffer({ src: second, dest: buffer, position: 0 });
}, 3000);
}
//
// helpers
//
/**
* Get total duration of all audio buffers.
*/
function getTotalDuration(buffers) {
let totalDuration = 0;
for (let i = 0; i < buffers.length; i++) {
totalDuration += buffers[i].duration;
}
return totalDuration;
}
async function getFileArrayBuffer(file) {
const result = await fetch(file, { mode: "no-cors" });
const body = await result.blob();
return await body.arrayBuffer();
}
async function getAudioBuffer({ audioCtx, file, }) {
const arrayBuffer = await getFileArrayBuffer(file);
return await audioCtx.decodeAudioData(arrayBuffer);
}
/**
* Create an AudioBufferSourceNode from an AudioBuffer.
*
*/
function sourceFromBuffer({ audioCtx, buffer, connect = true, loop = false, }) {
const source = audioCtx.createBufferSource();
source.buffer = buffer;
if (connect) {
source.connect(audioCtx.destination);
}
source.loop = loop;
return source;
}
/**
* Create a single combined buffer from a list of AudioBuffers
*/
function combinedAudioBuffer({ audioCtx, buffers, fill, }) {
const bufferCount = buffers.length;
if (bufferCount < 1) {
throw new Error("assertion failed: must have at least 1 buffer");
}
let bufferSize = buffers[0].length;
let sampleRate = buffers[0].sampleRate;
let numberOfChannels = buffers[0].numberOfChannels;
for (let i = 1; i < bufferCount; i++) {
const src = buffers[i];
if (sampleRate != src.sampleRate) {
throw new Error(`assertion failed: must have identical sampleRate`);
}
sampleRate = src.sampleRate;
if (numberOfChannels != src.numberOfChannels) {
throw new Error(`assertion failed: must have identical numberOfChannels`);
}
numberOfChannels = src.numberOfChannels;
bufferSize += src.length;
}
const combinedBuffer = audioCtx.createBuffer(numberOfChannels, bufferSize, sampleRate);
if (!fill) {
return combinedBuffer;
}
let position = 0;
for (let i = 0; i < bufferCount; i++) {
const src = buffers[i];
position = copyBuffer({ src, dest: combinedBuffer, position });
}
return combinedBuffer;
}
/**
* Copy one AudioBuffer to another
*
* @returns {number} new position
*/
function copyBuffer({ src, dest, position = 0, }) {
if (src.numberOfChannels != dest.numberOfChannels) {
throw new Error("assertion failed: src and dest must have same numberOfChannels");
}
for (let ch = 0; ch < src.numberOfChannels; ch++) {
dest.copyToChannel(src.getChannelData(ch), ch, position);
}
return position + src.length;
}
let audioCtx; // set up a global handle for easier debugging
function getAudioCtx() {
if (!audioCtx) {
audioCtx = new window.AudioContext();
}
return audioCtx;
}
//
// handlers
//
document.body.addEventListener("click", function listener() {
document.body.removeEventListener("click", listener);
main();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment