夏风

用Web音频API来做一个音频可视化工具

原文链接: noisehack.com

如果你曾经想过像MilkDrop这样的音乐可视化工具是怎么做的,那么这篇文章就是为你准备的。我们将从使用Canvas API来做简单的可视化入手,然后慢慢转移到用WebGL着色器来做更复杂的可视化。

使用Canvas API的波形图可视化

做一个音频可视化工具所需的第一件东西就是一些音频。现在我们有两个选项:一个是从A3到A6的扫描和我做的一首歌(由Pye Corner Audio重建轨道名字叫“Zero Center”)。

Saw Sweep Play Song(译者注:原文这里是两个按钮可以听这两个音频的效果,下同)

所有的音频可视化工具都需要的第二件事是获取音频数据的方式。Web Audio API为此提供了AnalyserNode 这个接口。除了提供了原始的波形(也叫做时间域)数据,它还提供了访问音频频谱(也叫频域)数据的方法。使用AnalyserNode这个接口很简单:创建一个AnalyserNode.frequencyBinCount长度的类型化数组,然后调用AnalyserNode.getFloatTimeDomainData这个方法用当前的波形数据来填充这个数组。

const analyser = audioContext.createAnalyser()
masterGain.connect(analyser)

const waveform = new Float32Array(analyser.frequencyBinCount)
analyser.getFloatTimeDomainData(waveform)

此时,waveform数组将包含与通过masterGain节点播放的音频波形相对应的从-1到1的值。 这只是目前正在播放的一个片刻。为了使之有用,我们需要周期性的更新这个数组。在requestAnimationFrame的回调函数里更新这个数组是一个好主意。

;(function updateWaveform() {
  requestAnimationFrame(updateWaveform)
  analyser.getFloatTimeDomainData(waveform)
})()

现在将会每秒更新这个waveform数组60次,这样,我们最后一个需要的东西:一些绘图代码。在这个例子中,我们只需简单地像示波器在y轴上绘制波形。

const scopeCanvas = document.getElementById('oscilloscope')
scopeCanvas.width = waveform.length
scopeCanvas.height = 200
const scopeContext = scopeCanvas.getContext('2d')

;(function drawOscilloscope() {
  requestAnimationFrame(drawOscilloscope)
  scopeContext.clearRect(0, 0, scopeCanvas.width, scopeCanvas.height)
  scopeContext.beginPath()
  for (let i = 0; i < waveform.length; i++) {
    const x = i
    const y = (0.5 + waveform[i] / 2) * scopeCanvas.height;
    if (i == 0) {
      scopeContext.moveTo(x, y)
    } else {
      scopeContext.lineTo(x, y)
    }
  }
  scopeContext.stroke()
})()

Saw Sweep Play Song

尝试多次点击“锯切扫描”按钮,看看波形如何响应

使用Canvas API进行频谱可视化。

AnalyserNode接口还提供有关音频中当前存在的频率的数据。它对波形数据运行FFT(傅立叶变换),并将这些值暴露为一个数组。在这种情况下,我们将要求数据为Uint8Array,因为0-255范围内的值正是执行Canvas像素操作时所需要的值的范围。

const spectrum = new Uint8Array(analyser.frequencyBinCount)
;(function updateSpectrum() {
  requestAnimationFrame(updateSpectrum)
  analyser.getByteFrequencyData(spectrum)
})()

waveform数组类似,spectrum数组现在将使用当前的音频频谱每秒更新60次。这些值对应于频谱的给定片段的音量,从低频到高频排列。让我们看看如何使用这些数据来创建一个被称为声谱图的可视化。

const spectroCanvas = document.getElementById('spectrogram')
spectroCanvas.width = spectrum.length
spectroCanvas.height = 200
const spectroContext = spectroCanvas.getContext('2d')
let spectroOffset = 0

;(function drawSpectrogram() {
  requestAnimationFrame(drawSpectrogram)
  const slice = spectroContext.getImageData(0, spectroOffset, spectroCanvas.width, 1)
  for (let i = 0; i < spectrum.length; i++) {
    slice.data[4 * i + 0] = spectrum[i] // R
    slice.data[4 * i + 1] = spectrum[i] // G
    slice.data[4 * i + 2] = spectrum[i] // B
    slice.data[4 * i + 3] = 255         // A
  }
  spectroContext.putImageData(slice, 0, spectroOffset)
  spectroOffset += 1
  spectroOffset %= spectroCanvas.height
})()

Saw Sweep Play Song

我发现谱图是分析音频的最有用的工具之一,例如找出正在播放的和弦或调试不正确的合成器。光谱图也适用于寻找复活节彩蛋

可视化与WebGL着色器

我最喜欢的电脑图形技术是使用WebGL的全屏像素着色器。通常,几个像素着色器与3D几何结合使用来呈现场景,但是今天我们将使用单个像素着色器(也称为片段着色器)来跳过几何图形并渲染整个场景。与Canvas API相比,它需要引用更多的文件,但最终的结果是非常值得的。

首先,我们需要绘制一个覆盖整个屏幕的矩形(也称为四边形)。片段着色器将被绘制的在这上面。

function initQuad(gl) {
  const vbo = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo)
  const vertices = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
  gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW)
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
}

function renderQuad(gl) {
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
}

现在我们有全屏四边形(技术上它是两个半屏三角形),我们需要一个着色器程序。 这是一个使用顶点着色器和片段着色器的函数,并返回一个已经编译好的着色器程序。

function createShader(gl, vertexShaderSrc, fragmentShaderSrc) {
  const vertexShader = gl.createShader(gl.VERTEX_SHADER)
  gl.shaderSource(vertexShader, vertexShaderSrc)
  gl.compileShader(vertexShader)
  if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
    throw new Error(gl.getShaderInfoLog(vertexShader))
  }

  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
  gl.shaderSource(fragmentShader, fragmentShaderSrc)
  gl.compileShader(fragmentShader)
  if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
    throw new Error(gl.getShaderInfoLog(fragmentShader))
  }

  const shader = gl.createProgram()
  gl.attachShader(shader, vertexShader)
  gl.attachShader(shader, fragmentShader)
  gl.linkProgram(shader)
  gl.useProgram(shader)

  return shader
}

这个可视化的顶点着色器非常简单,它只是穿过顶点位置而不会修改它。

attribute vec2 position;

void main(void) {
  gl_Position = vec4(position, 0, 1);
}

这个片段着色器就比较有趣了,我们将从由Danguafer提供的这个着色器开始,并做出一些战略性的修改,以便对音频进行响应。

precision mediump float;

uniform float time;
uniform vec2 resolution;
uniform sampler2D spectrum;

void main(void) {
  vec3 c;
  float z = 0.1 * time;
  vec2 uv = gl_FragCoord.xy / resolution;
  vec2 p = uv - 0.5;
  p.x *= resolution.x / resolution.y;
  float l = 0.2 * length(p);
  for (int i = 0; i < 3; i++) {
    z += 0.07;
    uv += p / l * (sin(z) + 1.0) * abs(sin(l * 9.0 - z * 2.0));
    c[i] = 0.01 / length(abs(mod(uv, 1.0) - 0.5));
  }
  float intensity = texture2D(spectrum, vec2(l, 0.5)).x;
  gl_FragColor = vec4(c / l * intensity, time);
}

这个关键是将输出颜色与声谱强度相乘。 另一个区别是我们将“l”缩放为0.2,因为大部分音频都在频谱纹理里的前20%。

到底什么是频谱纹理?它是从之前的声谱数组,复制到1024x1的图像。 以下讲的是如何实现(波形数据可以使用相同的技术):

function createTexture(gl) {
  const texture = gl.createTexture()
  gl.bindTexture(gl.TEXTURE_2D, texture)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  return texture
}

function copyAudioDataToTexture(gl, audioData, textureArray) {
  for (let i = 0; i < audioData.length; i++) {
    textureArray[4 * i + 0] = audioData[i] // R
    textureArray[4 * i + 1] = audioData[i] // G
    textureArray[4 * i + 2] = audioData[i] // B
    textureArray[4 * i + 3] = 255          // A
  }
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, audioData.length, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, textureArray)
}

随着所有这一切准备完毕,我们终于准备绘制可视化了。 首先,我们初始化画布并编译着色器。

const fragCanvas = document.getElementById('fragment')
fragCanvas.width = fragCanvas.parentNode.offsetWidth
fragCanvas.height = fragCanvas.width * 0.75
const gl = fragCanvas.getContext('webgl') || fragCanvas.getContext('experimental-webgl')
const vertexShaderSrc = document.getElementById('vertex-shader').textContent
const fragmentShaderSrc = document.getElementById('fragment-shader').textContent
const fragShader = createShader(gl, vertexShaderSrc, fragmentShaderSrc)

接下来,我们初始化这个着色器变量: position, time, resolution,还有我们最感兴趣的一个变量spectrum

const fragPosition = gl.getAttribLocation(fragShader, 'position')
gl.enableVertexAttribArray(fragPosition)
const fragTime = gl.getUniformLocation(fragShader, 'time')
gl.uniform1f(fragTime, audioContext.currentTime)
const fragResolution = gl.getUniformLocation(fragShader, 'resolution')
gl.uniform2f(fragResolution, fragCanvas.width, fragCanvas.height)
const fragSpectrumArray = new Uint8Array(4 * spectrum.length)
const fragSpectrum = createTexture(gl)

现在设置了这些变量,我们初始化全屏四边形并启动渲染循环。 在每个框架上,我们更新time变量和spectrum纹理,并渲染这个四边形。

initQuad(gl)

;(function renderFragment() {
  requestAnimationFrame(renderFragment)
  gl.uniform1f(fragTime, audioContext.currentTime)
  copyAudioDataToTexture(gl, spectrum, fragSpectrumArray)
  renderQuad(gl)
})()

Saw Sweep Play Song(两个按钮,可以演示效果)

如您所见,全屏片段着色器相当强大。 如果有更多的想法,可以花一些时间探索ShadertoyThe Book of Shaders。 使着色器对音频作出反应是吸引更多生命力的好方法,正如我们所看到的,Web Audio API使其易于操作。 如果您最终制作出酷炫的音乐可视化,请在评论中分享!


如果你很喜欢这篇文章的话,可以订阅newsletter或者在Twitter上关注Noisehack