Last active
December 29, 2022 15:37
-
-
Save brianloveswords/3a4575696d0a4e812a0deee308c2945e to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @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