如何使用 JavaScript 获取麦克风的音频频率?

5
我需要创建类似于吉他调音器的工具,它可以识别声音频率,并确定我实际上正在演奏哪个和弦。这与我在网上找到的吉他调音器类似: https://musicjungle.com.br/afinador-online 但由于 webpack 文件的原因,我无法理解它是如何工作的。我想使此工具与后端分离。有人知道如何仅在前端中做到这一点吗?
我发现了一些旧的代码片段,它们不能一起使用...我需要新的思路。

2
任何任意的声音(这是在时间域中)都可以被输入到傅里叶变换(API通常称为FFT)中,它将返回相同的信息,但从时间域转换为频率域的表示形式...您将原始音频样本的数组输入到此FFT调用中,它将返回一个新的数组在这个频率域中...然后您需要循环遍历这个新的数组,计算存储在每个元素中的每个复数的幅度...最大的这种幅度将是该源输入音频的主导频率。 - Scott Stensland
2个回答

35

这里有一些问题需要解决,其中一些需要更多关于应用程序的信息。随着答案的进展,这个任务的规模会变得明显。

目前为止,这里有两个问题:

需要创建一种类似吉他调音器的东西...

1. 如何检测吉他音符的基本音高并将该信息反馈给用户在浏览器中?

那就是识别声音频率并确定我实际上正在演奏哪个和弦。

2. 如何检测吉他演奏的和弦?

第二个问题绝对不是一个简单的问题,但我们会逐步解决它。这不是一个编程问题,而是一个DSP问题。

问题1:浏览器中的音高检测

分解

如果您希望在浏览器中检测音符的音高,则应将其拆分为几个子问题。从直觉上讲,我们有以下JavaScript浏览器问题:

这不是一个详尽的列表,但它应该构成整个问题的大部分。

没有最小可重现示例,因此以上内容都不能被假定。

实现

基本实现将使用A. v. Knesebeck和U. Zölzer论文[1]中概述的自相关方法来表示单个基本频率(f0)的数字表示。

还有其他方法,其中混合和匹配滤波器和音高检测算法,我认为远远超出了合理答案的范围。

注意:Web Audio API在所有浏览器上实现并不相同。您应该检查每个主要浏览器,并在程序中进行适当的调整。以下内容在Google Chrome中进行了测试,因此在其他浏览器中可能会有所不同。

HTML

我们的页面应包括

  • 一个用于显示频率的元素
  • 一个用于启动音高检测的元素

一个更全面的界面可能会将以下操作分开:

  • 请求麦克风权限
  • 开始麦克风流
  • 处理麦克风流

但为了简洁起见,它们将被包装成一个单独的元素。这给我们提供了一个基本的HTML页面。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Pitch Detection</title>
</head>
<body>
<h1>Frequency (Hz)</h1>
<h2 id="frequency">0.0</h2>
<div>
    <button onclick="startPitchDetection()">
        Start Pitch Detection
    </button>
</div>
</body>
</html>

我们稍微有点着急使用<button onclick="startPitchDetection()">。我们将把操作包装在一个名为startPitchDetection的函数中。

变量的调色板

对于自相关音高检测方法,我们的变量调色板需要包括:

  • 音频上下文
  • 麦克风流
  • 分析器节点
  • 用于音频数据的数组
  • 用于相关信号的数组
  • 用于相关信号峰值的数组
  • 指向频率的DOM引用

这样我们就有了类似以下的内容

let audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let microphoneStream = null;
let analyserNode = audioCtx.createAnalyser()
let audioData = new Float32Array(analyserNode.fftSize);;
let corrolatedSignal = new Float32Array(analyserNode.fftSize);;
let localMaxima = new Array(10);
const frequencyDisplayElement = document.querySelector('#frequency');

一些值被留空,因为在麦克风流被激活之前它们是未知的。在 let localMaxima = new Array(10); 中的 10 有点任意。这个数组将存储相关信号连续极大值之间的样本距离。

主脚本

我们的 <button> 元素具有 startPitchDetectiononclick 函数,因此需要它。我们还需要:

  • 一个更新函数(用于更新显示)
  • 一个自相关函数,返回一个音高

然而,我们首先要做的是请求使用麦克风的权限。为了实现这一点,我们使用 navigator.mediaDevices.getUserMedia, 它将返回一个 Promise。根据 MDN 文档所述,这给我们提供了以下内容:

navigator.mediaDevices.getUserMedia({audio: true})
.then((stream) => {
  /* use the stream */
})
.catch((err) => {
  /* handle the error */
});

太好了!现在我们可以开始将主要功能添加到then函数中。

我们的事件顺序应该是:

  • 启动麦克风流
  • 将麦克风流连接到分析器节点
  • 设置定时回调来
    • 从分析器节点获取最新的时间域音频数据
    • 获取自相关导出的音高估计值
    • 使用该值更新HTML元素

除此之外,还需要在catch方法中添加错误日志记录。

然后,所有这些都可以包装到startPitchDetection函数中,得到类似以下的内容:

function startPitchDetection()
{
    navigator.mediaDevices.getUserMedia ({audio: true})
        .then((stream) =>
        {
            microphoneStream = audioCtx.createMediaStreamSource(stream);
            microphoneStream.connect(analyserNode);

            audioData = new Float32Array(analyserNode.fftSize);
            corrolatedSignal = new Float32Array(analyserNode.fftSize);

            setInterval(() => {
                analyserNode.getFloatTimeDomainData(audioData);

                let pitch = getAutocorrolatedPitch();

                frequencyDisplayElement.innerHTML = `${pitch}`;
            }, 300);
        })
        .catch((err) =>
        {
            console.log(err);
        });
}

setInterval 的更新间隔为 300 是任意的。一些试验将决定哪个间隔对你最好。你甚至可能希望让用户控制这个,但这超出了本问题的范围。

下一步���实际定义 getAutocorrolatedPitch() 做什么,所以让我们分解一下自相关是什么。

自相关是将信号与其自身进行卷积的过程。任何时候,当结果从正变为负的变化率时,被定义为局部最大值。从相关信号的开始到第一个最大值之间的样本数应该是f0的样本周期。我们可以继续寻找后续的极大值并取平均值,这应该会稍微提高准确性。有些频率没有整个样本周期,例如在44100 Hz的采样率下,440 Hz的周期为100.227。通过单个最大值,我们技术上永远无法准确检测到这个440 Hz的频率,结果总是441 Hz(44100/100)或436 Hz(44100/101)。

对于我们的自相关函数,我们需要:

  • 跟踪已检测到的最大值数量
  • 最大值之间的平均距离
我们的函数应该先执行自相关,找到本地最大值的样本位置,然后计算这些最大值之间的平均距离。这将给出一个类似于以下内容的函数:
function getAutocorrolatedPitch()
{
    // First: autocorrolate the signal

    let maximaCount = 0;

    for (let l = 0; l < analyserNode.fftSize; l++) {
        corrolatedSignal[l] = 0;
        for (let i = 0; i < analyserNode.fftSize - l; i++) {
            corrolatedSignal[l] += audioData[i] * audioData[i + l];
        }
        if (l > 1) {
            if ((corrolatedSignal[l - 2] - corrolatedSignal[l - 1]) < 0
                && (corrolatedSignal[l - 1] - corrolatedSignal[l]) > 0) {
                localMaxima[maximaCount] = (l - 1);
                maximaCount++;
                if ((maximaCount >= localMaxima.length))
                    break;
            }
        }
    }

    // Second: find the average distance in samples between maxima

    let maximaMean = localMaxima[0];

    for (let i = 1; i < maximaCount; i++)
        maximaMean += localMaxima[i] - localMaxima[i - 1];

    maximaMean /= maximaCount;

    return audioCtx.sampleRate / maximaMean;
}
问题

一旦您实施了这个方案,您可能会发现有几个问题。

  • 频率结果有些不稳定
  • 显示方法对于调谐目的来说不够直观

不稳定的结果是因为自相关本身并不是一个完美的解决方案。您需要尝试首先过滤信号并聚合其他方法。您还可以尝试限制信号或仅在信号高于某个阈值时分析信号。您还可以增加执行检测的速率并平均结果。

其次,显示方法受到限制。音乐家不会欣赏简单的数字结果,而是更喜欢某种图形反馈。同样,这超出了问题的范围。

完整页面和脚本
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Pitch Detection</title>
</head>
<body>
<h1>Frequency (Hz)</h1>
<h2 id="frequency">0.0</h2>
<div>
    <button onclick="startPitchDetection()">
        Start Pitch Detection
    </button>
</div>
<script>
    let audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    let microphoneStream = null;
    let analyserNode = audioCtx.createAnalyser()
    let audioData = new Float32Array(analyserNode.fftSize);;
    let corrolatedSignal = new Float32Array(analyserNode.fftSize);;
    let localMaxima = new Array(10);
    const frequencyDisplayElement = document.querySelector('#frequency');

    function startPitchDetection()
    {
        navigator.mediaDevices.getUserMedia ({audio: true})
            .then((stream) =>
            {
                microphoneStream = audioCtx.createMediaStreamSource(stream);
                microphoneStream.connect(analyserNode);

                audioData = new Float32Array(analyserNode.fftSize);
                corrolatedSignal = new Float32Array(analyserNode.fftSize);

                setInterval(() => {
                    analyserNode.getFloatTimeDomainData(audioData);

                    let pitch = getAutocorrolatedPitch();

                    frequencyDisplayElement.innerHTML = `${pitch}`;
                }, 300);
            })
            .catch((err) =>
            {
                console.log(err);
            });
    }

    function getAutocorrolatedPitch()
    {
        // First: autocorrolate the signal

        let maximaCount = 0;

        for (let l = 0; l < analyserNode.fftSize; l++) {
            corrolatedSignal[l] = 0;
            for (let i = 0; i < analyserNode.fftSize - l; i++) {
                corrolatedSignal[l] += audioData[i] * audioData[i + l];
            }
            if (l > 1) {
                if ((corrolatedSignal[l - 2] - corrolatedSignal[l - 1]) < 0
                    && (corrolatedSignal[l - 1] - corrolatedSignal[l]) > 0) {
                    localMaxima[maximaCount] = (l - 1);
                    maximaCount++;
                    if ((maximaCount >= localMaxima.length))
                        break;
                }
            }
        }

        // Second: find the average distance in samples between maxima

        let maximaMean = localMaxima[0];

        for (let i = 1; i < maximaCount; i++)
            maximaMean += localMaxima[i] - localMaxima[i - 1];

        maximaMean /= maximaCount;

        return audioCtx.sampleRate / maximaMean;
    }
</script>
</body>
</html>

问题2:检测多个音符

目前为止,我认为我们都可以同意这个答案有点失控了。到目前为止,我们只涵盖了单一的音高检测方法。请参见Ref [2, 3, 4],获取一些关于多个f0检测算法的建议。

本质上,这个问题将归结为检测所有的f0,并将结果音符与和弦字典进行匹配。为此,您至少应该做一些工作。任何关于DSP的问题可能都应该指向https://dsp.stackexchange.com。您将会在有关音高检测算法的问题方面有很多选择。

参考资料

  1. A. v. Knesebeck和U. Zölzer,“用于实时吉他效果的音高跟踪器比较”,发表于2010年9月6日至10日奥地利格拉茨举办的第13届数字音频效果国际会议(DAFx-10)的论文集中。
  2. A. P. Klapuri,“一种感知动机的多F0估计方法”,2005年IEEE应用信号处理到音频和声学的研讨会,2005年,pp. 291-294,doi:10.1109/ASPAA.2005.1540227。
  3. A. P. Klapuri,"基于谐波和光谱平滑度的多基频估计",发表于2003年11月《IEEE Transactions on Speech and Audio Processing, vol. 11, no. 6, pp. 804-816》期刊上,doi:10.1109/TSA.2003.815516。
  4. A. P. Klapuri,“基于光谱平滑度原理的多音高估计和声音分离”,2001年IEEE国际声学、语音和信号处理会议论文集,2001年,pp. 3381-3384 vol.5,doi:10.1109/ICASSP.2001.940384。

8
我认为我们都可以达成共识,这个回答有点过度了。不过,它是我最近读过的最有趣的回答之一! - 4D45
3
非常好的回答!我怀疑强健的多声部和弦识别,特别是当与现实世界的音调混合在一起时,将比单声部更难,这是一个活跃研究领域,有一些基于DSP、AI/ML的解决方案。在DSP方面,这个看起来很有趣:https://silo.tips/download/automatic-chord-recognition-from-audio-using-enhanced-pitch-class-profile,并指向一系列信息来源。我认为一个真正强健的解决方案也需要理解上下文(例如整个乐曲的调性)。所有这些内容都太多了,不适合在SO上回答。 - Euan Smith
1
对于了解多声部“和弦”识别机制的洞察,可能会对某个这样设备的专利细节感兴趣,可以在https://patents.google.com/patent/US8334449B2/en找到。 - ed2
1
“maxima”的单数形式是“maximum”。感谢您精彩的写作! - lmat - Reinstate Monica

1
我想这将取决于您如何构建应用程序。没有太多规格细节很难提供帮助。不过,以下是几个选项供您选择。
有几个流选项,例如:
- mic 如果您正在使用React,则可以:
- react-mic 或者,如果您想要使用一些基本的vanilla JS:
- Web Fundamentals: Recording Audio

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