使用C#开发跨平台调音器和节拍器(Windows&Android)
前言
本项目仅以个人学习为目的进行开发,无法保证功能在你的设备上正常运行。如果你正在寻找专业级的安卓应用,对于调音器我推荐Boss Tuner
,对于节拍器我推荐Stage Metronome
。
本文记录了对整个开发过程的总结,同时也是对这方面的资料进行一些补充,你可以根据目录查看你感兴趣的部分。
界面预览:

1. 选择跨平台框架
目前主流的跨平台框架有:MAUI、Uno、Avalonia。我每一个都试用了一下,最终决定使用Avalonia:
- MAUI:界面bug多,.Net 8版本中的Editor甚至完全用不了(渲染错误)。正如Github上的Discussions中所说,MAUI是一个还没有准备好的框架。
- Uno:使用Uno开发时,在桌面端运行正常,在安卓模拟器中运行也正常,但是一旦部署到我手机上时,所有控件都不能正常渲染,我查不出来原因,最后也放弃了。
- Avalonia:简单测试了一下,桌面端和手机上都可以正常运行,并且有个优点是不同平台的渲染效果是一样的。
2. 大致的项目结构
这几个跨平台框架的项目结构都基本类似,一个不依赖平台的通用项目,和多个依赖它的平台项目。所以界面代码、调音器/节拍器算法应该放在通用项目里,而音频的录制和播放实现则放在平台项目里,通用项目里只使用对应的抽象类即可:
public abstract class AudioRecorder
{
public delegate void BufferUpdatedHandler(byte[] buffer, WaveFormat waveFormat);
public bool IsRecording { get; protected set; }
public abstract event BufferUpdatedHandler BufferUpdated;
public abstract Task Start();
public abstract Task Pause();
}
public abstract class AudioPlayer
{
public abstract Task Play(byte[] buffer);
}
3. 调音器部分
3.1 Windows录音实现
可以选择NAudio的WaveInEvent或WasapiCapture。
实际使用时,WaveInEvent默认的录制采样率是8000Hz、buffer大小是800(基于float),这对于基频检测是不够准确的,不过它也可以手动设置。而WasapiCapture默认就是48000Hz、buffer大小也足够大(2800+,基于float),所以最后使用了WasapiCapture。
这些默认参数我认为可能跟系统当前的音频输入设备有关,我在开发时一直是使用立体声混音进行测试。
3.2 Android录音实现
安卓平台的音频类有MediaRecorder和AudioRecord,MediaRecorder似乎只支持将音频写入到文件中,而AudioRecord支持直接操作字节流,因此此处使用AudioRecord。
初始化AudioRecord时,需要指定AudioSource、采样率、通道数、位深度和总buffer大小,以下是我开发时的参数选择和遇到的问题,也许在你的设备上情况会不一样:
- AudioSource:使用AudioSource.Mic,来自麦克风
- 采样率:根据安卓官方文档,44100Hz是唯一保证在所有设备上工作的频率
- 通道数:选择Mono只能获取到空音频流,而Stereo正常
- 位深度:选择Pcm8bit、Pcm24bit、Pcm32bit只能获取到空音频流,而Pcm16bit正常
- 总buffer大小:文档建议比读取时的buff长度大即可,这里我选择了两倍大小(32000,基于byte)
3.3 音频字节数组转换为归一化浮点数组
为了方便对音频数据进行后续分析,需要将不同采样率、位深度、通道的字节数组转换为归一化的浮点数组。按理说NAudio应该有现成的方法,不过懒得找了,现写了一个(只支持8/16/32位):
private float[] ToFloatArray(byte[] bytes, WaveFormat waveFormat)
{
var dataLength = waveFormat.BitsPerSample / 8;
var floatArray = new float[bytes.Length / dataLength / waveFormat.Channels];
switch (dataLength)
{
case 1:
for (var i = 0; i < bytes.Length; i += waveFormat.Channels * dataLength)
floatArray[i / dataLength / waveFormat.Channels] = bytes[i] / 128f;
break;
case 2:
for (var i = 0; i < bytes.Length; i += waveFormat.Channels * dataLength)
floatArray[i / dataLength / waveFormat.Channels] = BitConverter.ToInt16(bytes, i) / 32768f;
break;
case 4:
for (var i = 0; i < bytes.Length; i += waveFormat.Channels * dataLength)
floatArray[i / dataLength / waveFormat.Channels] = BitConverter.ToSingle(bytes, i);
break;
}
return floatArray;
}
3.4 基频检测算法
在此之前我没有任何信号处理相关的知识,我的数学也不好,于是我去找chatGPT生成了一个Yin算法和一个基于FFT的算法的代码,结果都用不了,我也不会改。网上也找不到C#的轮子,找了很久才找到一篇勉强能看懂的论文:A_Smarter_Way_to_Find_Pitch,于是我尝试将它编写成代码。
第一步使用的是归一化自相关函数,最终公式如下:
写成代码:
private static float[] NAC(float[] data)
{
var result = new float[data.Length];
for (var w = 0; w < data.Length; w++)
{
var acf = 0f;
var squareSum = 0f;
for (var i = 0; i < data.Length - w; i++)
{
acf += data[i] * data[i + w];
squareSum += data[i] * data[i] + data[i + w] * data[i + w];
}
result[w] = squareSum != 0 ? 2 * acf / squareSum : 0;
}
return result;
}
这个函数计算了延迟τ与音频信号的相关程度,如果某个τ的相关程度为1,则表明音频信号在以τ为周期进行重复。所以我们的目标应该是找到这个函数的峰值,不过在实际运行时,直接取最大值总是会取到数组的最后一位,这显然是不对的。为了避免这个情况,我的想法是,先找出最大值,然后设置一个阈值(比如0.95),从头开始遍历数组,取第一个大于最大值*阈值的下标为结果。
此外,由于计算过程中τ是离散的,为了增加准度,需要引入插值算法。实际上,不使用插值算法会使精准度变得特别差。我采用的是二次插值算法,以下是代码:
private static float Period(float[] data, bool useInterpolation, float threshold = 0.95f)
{
var max = 0f;
var maxIndex = 0;
for (var i = 1; i < data.Length; i++)
{
max = data[i] > max ? data[i] : max;
maxIndex = i;
}
for (var i = 1; i < data.Length - 1; i++)
if (data[i] > max * threshold && data[i] >= data[i - 1] && data[i] > data[i + 1])
return useInterpolation ? QuadraticInterpolation(data, i) : i;
return useInterpolation ? QuadraticInterpolation(data, maxIndex) : maxIndex;
}
private static float QuadraticInterpolation(float[] data, int index)
{
if (index > 0 && index < data.Length - 1)
{
var a = data[index - 1];
var b = data[index];
var c = data[index + 1];
var vertex = index + 0.5f * (a - c) / (a - 2 * b + c);
return vertex;
}
return index;
}
获取到周期后,需要将其转换为频率。要注意的是,这里周期的单位是下标,换算为物理时间需要乘以秒,再然后频率=周期的倒数=,代码:
public static float DetectFrequency(float[] data, int sampleRate, bool useInterpolation)
{
var d = NAC(data);
var p = Period(d, useInterpolation);
return sampleRate / p;
}
3.5 频率转换为十二平均律音高
我将所有音高的频率数据存储到列表里,然后通过二分查找找到最接近当前频率的标准音,然后计算频率与标准音之间的偏差信息:
public static readonly IReadOnlyList<(float Frequency, string NoteName, int Octave)> Data =
new List<(float Frequency, string NoteName, int Octave)>();
private static int FindClosestIndex(float value)
{
var left = 0;
var right = Data.Count - 1;
while (left <= right)
{
var mid = left + (right - left) / 2;
if (Math.Abs(Data[mid].Frequency - value) < 1e-9)
return mid;
if (Data[mid].Frequency < value)
left = mid + 1;
else
right = mid - 1;
}
// 在循环结束时,left 和 right 之间的差值可能为 1,选择离目标值更近的一侧
if (left > 0 && left < Data.Count &&
Math.Abs(Data[left - 1].Frequency - value) < Math.Abs(Data[left].Frequency - value))
return left - 1;
if (left < Data.Count)
return left; // 目标值大于数组最大值,返回数组最后一个元素的下标
return Data.Count - 1;
}
public static ((float Frequency, string NoteName, int Octave) NoteData, float Delta, float NormalizeDelta,
bool Acceptable) GetNoteInformation(float frequency)
{
var closestIndex = FindClosestIndex(frequency);
var delta = frequency - Data[closestIndex].Frequency;
float range;
float normalizeDelta;
if (delta >= 0) // 高于标准音
{
if (closestIndex + 1 >= Data.Count) // 超出范围
range = (Data[closestIndex].Frequency - Data[closestIndex - 1].Frequency) / 2; // 取前一个间距
else
range = (Data[closestIndex + 1].Frequency - Data[closestIndex].Frequency) / 2; // 取下一个间距
if (delta > range) // 高于B9
return ((frequency, "", 9), 0, 0, false);
normalizeDelta = delta / range;
}
else // 低于标准音
{
if (closestIndex - 1 < 0) // 超出范围
range = (Data[closestIndex + 1].Frequency - Data[closestIndex].Frequency) / 2; // 取下一个间距
else
range = (Data[closestIndex].Frequency - Data[closestIndex - 1].Frequency) / 2; // 取前一个间距
if (-delta > range) // 低于C0
return ((frequency, "", 0), 0, 0, false);
normalizeDelta = delta / range;
}
// 误差是否可接受
var acceptable = Math.Abs(normalizeDelta) <= 0.25;
return (Data[closestIndex], delta, normalizeDelta, acceptable);
}
至此,所有调音器需要的数据已经能获取到,将其呈现到界面上即可。
3.6 准确度如何?
- 使用无干扰的音频输入时:在C0-C6(16-1000Hz)时,误差小于0.5Hz;C6以上误差开始变大,并且有时会识别为半频或者倍频;C7以上结果不再可用。这里的数据是播放DAW的合成器音源然后从立体声混音中获取得到。
- 使用麦克风输入时:目标声音需要明显大于环境噪音,否则无法识别到,例如不插电的电吉他琴弦声无法被识别,插入音箱后调整到一定音量就可以。在这一点上,商业级的应用做得更好,比如Boss Tuner等,即便是不插电也可以很好的识别到。我认为他们的实现应该是对输入音频做了特殊处理,以强调吉他弦的声音。我本来想去改善这个问题,但是失败了——我根本看不懂网上的关于降噪算法、滤波算法的资料。
4. 节拍器部分
4.1 Windows播放实现
首先要考虑一个问题,当BPM非常高的时候,前一个采样还没播放结束时,后一个采样就要开始播放了,如果调用常规的播放API显然是不能满足的。好在NAudio的Readme里有提及这种情况:fire-and-forget-audio-playback-with。其中有一个关键的工具:MixingSampleProvider,它可以将即将播放的音频数据与当前在缓冲区中未播放的音频数据进行混合,从而达成刚刚说的效果。这时只需要初始化一次WasapiOut,然后让它一直播放即可,代码:
public class WindowsAudioPlayer : AudioPlayer
{
private readonly MixingSampleProvider _mixingSampleProvider;
private readonly WasapiOut _wasapiOut;
public WindowsAudioPlayer()
{
_mixingSampleProvider = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(44100, 2))
{ ReadFully = true };
_wasapiOut = new WasapiOut(AudioClientShareMode.Shared, 100);
_wasapiOut.Init(_mixingSampleProvider);
_wasapiOut.Play();
}
public override async Task Play(byte[] buffer)
{
_mixingSampleProvider.AddMixerInput(new RawSourceWaveStream(buffer, 0, buffer.Length,
new WaveFormat(44100, 2)));
}
}
4.2 Android播放实现
与AudioRecord类似,播放实现使用AudioTrack。我本来打算自己写一个数据结构实现上面说的混合采样音频流,结果发现MixingSampleProvider可以直接拿来给AudioTrack用:
public class AndroidAudioPlayer : AudioPlayer
{
private readonly AudioTrack _audioTrack;
private readonly MixingSampleProvider _mixingSampleProvider;
public AndroidAudioPlayer()
{
_audioTrack = new AudioTrack.Builder()
.SetAudioAttributes(new AudioAttributes.Builder()
.SetUsage(AudioUsageKind.Game)
.SetContentType(AudioContentType.Sonification)
.Build())
.SetAudioFormat(new AudioFormat.Builder()
.SetEncoding(Encoding.Pcm16bit)
.SetSampleRate(44100)
.SetChannelMask(ChannelOut.Stereo)
.Build())
.SetBufferSizeInBytes(1024)
.SetPerformanceMode(AudioTrackPerformanceMode.LowLatency)
.Build();
_audioTrack.Play();
_mixingSampleProvider = new MixingSampleProvider(WaveFormat.CreateIeeeFloatWaveFormat(44100, 2));
Task.Run(async () =>
{
var buffer = new byte[1024];
var waveProvider = _mixingSampleProvider.ToWaveProvider16();
while (true)
{
var count = waveProvider.Read(buffer, 0, buffer.Length);
await _audioTrack.WriteAsync(buffer, 0, count);
}
});
}
public override async Task Play(byte[] buffer)
{
_mixingSampleProvider.AddMixerInput(new RawSourceWaveStream(buffer, 0, buffer.Length,
new WaveFormat(44100, 2)));
}
}
注意点的话,AudioTrack的buffer大小应该尽量小,不然播放第一次节拍采样时会有延迟,然后保证AudioTrack.WriteAsync的buffer大小不超过这个数值即可。
4.3 精确控制播放间隔与自定义节拍
一开始的实现是写了个死循环+Task.Delay,后面发现随机延迟太严重了,改用Timer实现。每次切换节拍时,停止Timer,设置Interval,再重新启动Timer。
另外我还设计了自定义节拍功能,通过形如"1,1,1,1/4"的表达式自定义采样播放的节拍。具体逻辑是,将1,1,1,1解析为音符时长占比,4解析为音符时值,那么这条表达式的意思就是:以四分音符为一拍,一小节有四拍。如果写成1,1,1/4,那么就是3/4拍;如果写成1,2,1/4,那么就是一个四分音符+一个二分音符+一个四分音符作为一个小节。小节的第一拍采样和其他采样不一样,以突出小节的切换。具体代码:
private (int[] Intervals, int BeatUnit) Meter = (new[] { 1, 1, 1, 1 }, 4);
private int _beatCount; // 小节内子节拍计数
private int _subBeatIndex; // 播放到第几个节拍
private int _subBeatCount; // 节拍之间子节拍计数
private void Play()
{
// ...
_timer.Interval = 4d * 60000d / _viewModel.Bpm / Meter.BeatUnit;
// ...
}
private void OnTimerOnElapsed(object? o, ElapsedEventArgs elapsedEventArgs)
{
if (_beatCount == 0)
{
_audioPlayer.Play(_sample1);
_subBeatIndex = 0;
_subBeatCount = 0;
}
else if (_subBeatCount == Meter.Intervals[_subBeatIndex])
{
_audioPlayer.Play(_sample2);
_subBeatCount = 0;
_subBeatIndex++;
}
_subBeatCount++;
_beatCount = (_beatCount + 1) % Meter.Intervals.Sum();
}
4.4 准确度如何?
使用120BPM,4/4拍进行测试:
Windows:与FLStudio节拍器进行对比,本应用的节拍在2分钟时延迟了10ms
Android:与Stage Metronome节拍器进行对比,本应用在前2拍时的间隔明显缩短,但后续的节拍稳定,直到2分钟时也没有明显偏离。这个现象时有时无,暂时不清楚原因是什么。

上方为本应用,下方为Stage Metronome节拍器
总结
这是我第一次使用C#编写跨平台应用,也是第一次开发调音器和节拍器,如果你对功能实现方面有更好的建议,欢迎评论指点。
代码已开源:TunerAndMetronome
试用下载:Releases
参考资料:
naudio/NAudio: Audio and MIDI library for .NET
Fire and Forget Audio Playback with NAudio
AudioRecord | Android Developers