音高检测 - Node.js

9
我正在开发一款电子应用程序,希望能够在桌面上测量吉他输入的音高。我的初始想法是逐个音高测量,因此,请告诉我傅里叶变换是否合适。根据评论,似乎不太好,因此我考虑使用谐波产品谱等方法。虽然我对node.js并不是很有经验,但目前为止,我已经成功地修改了有问题的microphone包,并进行了一些调整,以便从sox中获取wav格式数据。这是实际的代码,它生成进程并获取数据(简化了,它实际上有一个startCapture方法,它会生成录制过程):
const spawn = require('child_process').spawn;
const PassThrough = require('stream').PassThrough;

const audio = new PassThrough;
const info = new PassThrough;

const recordingProcess = spawn('sox', ['-d', '-t', 'wav', '-p'])
recordingProcess.stdout.pipe(audio);
recordingProcess.stderr.pipe(info);

在另一个js文件中,我监听数据事件:
mic.startCapture({format: 'wav'});
mic.audioStream.on('data', function(data) {
    /* data is Uint8Array[8192] */
});

好的,我正在获取一组数据,这似乎是一个很好的开始。 我知道我应该应用某种音高检测算法来开始音高分析。

我是否朝着正确的方向前进?这些数据应该在什么格式下呈现? 我如何使用这些数据进行音高检测?


是的!我喜欢这个想法,顺便说一句,希望你能制作一个调音器 ;) 我一直想要一个命令行吉他调音器。 - theonlygusti
1
使用FFT不是衡量音高的好方法,特别是如果您想要足够准确的调音器。有更好的音高检测算法,例如谐波积谱。请注意:音高频率之间存在微妙但重要的区别 - 您想要测量的是音乐音高,而不是频率。 - Paul R
4
阅读上面关于音调的链接 - 音调是一种感知 - 当你有一个复杂的声音,比如一个乐器演奏一个单音符时,会有多个组成部分(通常是基频+谐波,但其中一些可能缺失) - 音调与基频有关,但所感知到的音调可能与基频的物理频率不同。请注意频率实际上只适用于单一组成部分,而音调适用于整个复杂声音。只有对于一个纯正弦波,它们才是相同的。 - Paul R
请注意,SO上已经有数十个非常相似的问题,通常是人们尝试实现乐器调音应用程序或类似应用程序,并错误地认为他们所需要的只是FFT - 您可能需要搜索标签[fft]、[dsp]、[pitch]、[frequency]等,或者只搜索“FFT tuner”。 - Paul R
1个回答

15

因为您正在获取带有WAV数据的缓冲区,所以您可以使用wav-decoder库来解析它,然后将其提供给pitchfinder库以获取音频的频率。

const Pitchfinder = require('pitchfinder')
const WavDecoder = require('wav-decoder')
const detectPitch = new Pitchfinder.YIN()

const frequency = detectPitch(WavDecoder.decode(data).channelData[0])

然而,由于您正在使用Electron,您也可以直接在Chromium中使用MediaStream Recording API。

首先,这仅适用于Electron 1.7+,因为它使用了Chromium 58,这是Chromium的第一个包含修复了导致 AudioContext无法从MediaRecorder解码音频数据的错误的版本。

另外,为了实现此代码,我将使用ES7 asyncawait语法,这应该在Node.js 7.6+和Electron 1.7+上正常运行。

所以假设您的Electron的index.html如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Frequency Finder</title>
  </head>
  <body>
    <h1>Tuner</h1>

    <div><label for="devices">Device:</label> <select id="devices"></select></div>

    <div>Pitch: <span id="pitch"></span></div>
    <div>Frequency: <span id="frequency"></span></div>

    <div><button id="record" disabled>Record</button></div>
  </body>

  <script>
    require('./renderer.js')
  </script>
</html>

现在让我们开始处理renderer脚本。首先,让我们设置一些将要使用的变量:

const audioContext = new AudioContext()
const devicesSelect = document.querySelector('#devices')
const pitchText = document.querySelector('#pitch')
const frequencyText = document.querySelector('#frequency')
const recordButton = document.querySelector('#record')
let audioProcessor, mediaRecorder, sourceStream, recording

好的,现在进入代码的其余部分。首先,让我们用所有可用的音频输入设备填充Electron窗口中的下拉式菜单<select>

navigator.mediaDevices.enumerateDevices().then(devices => {
  const fragment = document.createDocumentFragment()
  devices.forEach(device => {
    if (device.kind === 'audioinput') {
      const option = document.createElement('option')
      option.textContent = device.label
      option.value = device.deviceId
      fragment.appendChild(option)
    }
  })
  devicesSelect.appendChild(fragment)

  // Run the event listener on the `<select>` element after the input devices
  // have been populated. This way the record button won't remain disabled at
  // start.
  devicesSelect.dispatchEvent(new Event('change'))
})

在最后,你会注意到我们调用了一个在Electron窗口中设置的<select>元素上的事件。但是,请稍等,我们从未编写过该事件处理程序!让我们在刚才编写的代码之前添加一些代码:

// Runs whenever a different audio input device is selected by the user.
devicesSelect.addEventListener('change', async e => {
  if (e.target.value) {
    if (recording) {
      stop()
    }

    // Retrieve the MediaStream for the selected audio input device.
    sourceStream = await navigator.mediaDevices.getUserMedia({
      audio: {
        deviceId: {
          exact: e.target.value
        }
      }
    })

    // Enable the record button if we have obtained a MediaStream.
    recordButton.disabled = !sourceStream
  }
})

让我们实际编写一个处理程序来处理录制按钮,因为此时它什么也不做:

// Runs when the user clicks the record button.
recordButton.addEventListener('click', () => {
  if (recording) {
    stop()
  } else {
    record()
  }
})

现在我们展示音频设备,让用户选择它们,并拥有一个录音按钮……但我们仍有未实现的功能 -record()stop()
让我们停下来做一个架构决策。
我们可以在renderer.js中记录音频,抓取音频数据,并分析它以获得其音高。然而,分析音高的数据是一项昂贵的操作。因此,最好能够运行该操作的能力在进程外运行。
幸运的是,Electron 1.7带来了支持具有节点上下文的Web Worker。创建一个Web Worker将允许我们在不同的进程中运行昂贵的操作,因此它不会在运行时阻止主进程(和UI)。
因此,在考虑到这一点的同时,让我们假设我们将在audio-processor.js中创建一个Web Worker。我们稍后会进行实现,但我们将假设它接受一个对象{sampleRate, audioData}的消息,其中sampleRate是采样率,audioData是我们将传递给pitchfinderFloat32Array
让我们还假设:
  • 如果录制的处理成功,工作者将返回一个对象{frequency, key, octave}的消息 - 例如{frequency: 440.0, key:'A',octave:4}
  • 如果录制的处理失败,工作者将返回一个带有null的消息。
让我们编写我们的record函数:
function record () {
  recording = true
  recordButton.textContent = 'Stop recording'

  if (!audioProcessor) {
    audioProcessor = new Worker('audio-processor.js')

    audioProcessor.onmessage = e => {
      if (recording) {
        if (e.data) {
          pitchText.textContent = e.data.key + e.data.octave.toString()
          frequencyText.textContent = e.data.frequency.toFixed(2) + 'Hz'
        } else {
          pitchText.textContent = 'Unknown'
          frequencyText.textContent = ''
        }
      }
    }
  }

  mediaRecorder = new MediaRecorder(sourceStream)

  mediaRecorder.ondataavailable = async e => {
    if (e.data.size !== 0) {
      // Load the blob.
      const response = await fetch(URL.createObjectURL(data))
      const arrayBuffer = await response.arrayBuffer()
      // Decode the audio.
      const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
      const audioData = audioBuffer.getChannelData(0)
      // Send the audio data to the audio processing worker.
      audioProcessor.postMessage({
        sampleRate: audioBuffer.sampleRate,
        audioData
      })
    }
  }

  mediaRecorder.start()
}

一旦我们开始使用 MediaRecorder 录制,直到停止录制,我们才会收到 ondataavailable 处理程序的调用。这是编写 stop 函数的良好时机。

function stop () {
  recording = false
  mediaRecorder.stop()
  recordButton.textContent = 'Record'
}

现在我们只需要在 audio-processor.js 中创建我们的工作者即可。让我们继续并创建它。
const Pitchfinder = require('pitchfinder')

// Conversion to pitch from frequency based on technique used at
// https://www.johndcook.com/music_hertz_bark.html

// Lookup array for note names.
const keys = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']

function analyseAudioData ({sampleRate, audioData}) {
  const detectPitch = Pitchfinder.YIN({sampleRate})

  const frequency = detectPitch(audioData)
  if (frequency === null) {
    return null
  }

  // Convert the frequency to a musical pitch.

  // c = 440.0(2^-4.75)
  const c0 = 440.0 * Math.pow(2.0, -4.75)
  // h = round(12log2(f / c))
  const halfStepsBelowMiddleC = Math.round(12.0 * Math.log2(frequency / c0))
  // o = floor(h / 12)
  const octave = Math.floor(halfStepsBelowMiddleC / 12.0)
  const key = keys[Math.floor(halfStepsBelowMiddleC % 12)]

  return {frequency, key, octave}
}

// Analyse data sent to the worker.
onmessage = e => {
  postMessage(analyseAudioData(e.data))
}

现在,如果你把所有内容都运行起来……它不能正常工作!为什么呢?
我们需要更新main.js(或者您的主要脚本名称)这样当主电子窗口被创建时,Electron会告诉我们提供Node支持在网页工作者的上下文中。否则,那个require('pitchfinder')并不能做到我们想要的。
这很简单,我们只需要在窗口的webPreferences对象中添加nodeIntegrationInWorker: true即可。例如:
mainWindow = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: {
    nodeIntegrationInWorker: true
  }
})

现在,如果你运行你所编写的代码,你将得到一个简单的Electron应用程序,它可以让你录制一小段音频,测试其音高,并将该音高显示在屏幕上。这对于小片段的音频效果最佳,因为音频越长,处理所需时间越长。
如果你想要更完整的例子,比如能够实时监听和返回音高而不是让用户一直点击录制和停止,可以看一下我制作的electron-tuner app。随意查看源代码,以了解如何操作 - 我已经尽力确保其有良好的注释。
这是它的截图:

Screenshot of electron-tuner

希望这些内容能够对你的努力有所帮助。

1
我想告诉你,我为pitchfinder仓库创建了一个node-addon(名为node-pitchfinder)。它比原来的更快(在我的项目中,我需要在多个通道上进行实时检测,所以我需要更快的速度)。我还实现了MacLeod算法,这对于乐器表现更好(至少我认为对吉他是这样的)。 - Cristóvão Trevisan
点击录制没有任何反应,而点击停止会出现错误 Uncaught (in promise) ReferenceError: data is not defined - Cerin

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接