【问题标题】:Writing musical notes to a wav file将音符写入 wav 文件
【发布时间】:2025-12-03 08:20:33
【问题描述】:

我对如何记录音符(例如 A、B、C# 等)或和弦(同时多个音符)并将它们写入 wav 文件感兴趣。

据我了解,每个音符都有一个与之相关的特定频率(完美音高)-例如 A4(中间 C 上方的 A)是 440 Hz(完整列表 2/3 向下 This Page) .

如果我的理解是正确的,这个音高是在频域中,所以需要对其应用快速傅立叶逆变换来生成时域等价物?

我想知道的是:

  • 和弦是如何工作的?它们是球场的平均值吗?
  • 当 wav 文件的内容是波形时,如何指定播放每个音符的时间长度?
  • 如何将多个音符的结果进行反向 FFT 转换为字节数组,从而构成 wav 文件中的数据?
  • 与此相关的任何其他相关信息。

感谢您提供的任何帮助。如果给出代码示例,我使用的是 C#,我目前用于创建 wav 文件的代码如下:

int channels = 1;
int bitsPerSample = 8;
//WaveFile is custom class to create a wav file.
WaveFile file = new WaveFile(channels, bitsPerSample, 11025);

int seconds = 60;
int samples = 11025 * seconds; //Create x seconds of audio

// Sound Data Size = Number Of Channels * Bits Per Sample * Samples

byte[] data = new byte[channels * bitsPerSample/8 * samples];

//Creates a Constant Sound
for(int i = 0; i < data.Length; i++)
{
    data[i] = (byte)(256 * Math.Sin(i));
}
file.SetData(data, samples);

这会(以某种方式)产生恒定的声音 - 但我不完全理解代码与结果的关系。

【问题讨论】:

  • P.S,我知道这将是一个重要的答案,所以即使是指向某个地方的链接来解释这一点也会有所帮助。
  • 你可能会在这里找到一些东西sonicspot.com/guide/wavefiles.html
  • 从袜子开始。然后,如果您觉得需要“自己动手”,那么您就有了一个众所周知的基线。

标签: c# wav


【解决方案1】:

还没有人提到 Karplus Strong 弹拨弦算法。

Karplus–Strong string synthesis 这是一种非常简单的方法,可以生成逼真的弹拨弦音色。我已经使用它编写了和弦乐器/实时 MIDI 播放器。

你这样做:

首先,您要模拟什么频率?假设音乐会音高 A = 440Hz

假设您的采样率为 44.1kHz,即每个波长 44100 / 440 = 100.25 个样本。

让我们将它四舍五入到最接近的整数:100,并创建一个循环缓冲区长度为 100。

因此它将保持一个频率约为 440Hz 的驻波(请注意,它不准确,有一些方法可以解决这个问题)。

用-1和+1之间的随机静态填充它,并且:

DECAY = 0.99
while( n < 99999 )
    outbuf[n++] = buf[k]

    newVal = DECAY  *  ( buf[k] + buf_prev ) / 2

    buf_prev = buf[k]
    buf[k] = newVal

    k = (k+1) % 100

这是一个了不起的算法,因为它非常简单并且可以产生超级声音。

了解正在发生的事情的最佳方法是认识到时域中的随机静态是白噪声;频域中的随机静态。你可以把它想象成许多不同(随机)频率的波的合成。

接近 440Hz(或 2*440Hz、3*440Hz 等)的频率会在它们一次又一次地绕过环时对其自身产生建设性干扰。所以他们会被保存下来。其他频率会破坏性地干扰自身。

此外,平均作为低通滤波器 - 假设您的序列是 +1 -1 +1 -1 +1 -1,如果您正在平均对,那么每个平均值都为 0。但如果您的速度较慢像 0 0.2 0.3 0.33 0.3 0.2 一样的波浪......然后平均仍然会产生波浪。波越长,其能量被保存的越多——即平均导致的阻尼越小。

所以平均可以被认为是非常简单的低通滤波器。

当然会有一些复杂的情况,必须选择整数缓冲区长度会强制量化可能的频率,这在钢琴顶部变得明显。一切都是可以克服的,但它变得很难!

链接:

Delicious Max/MSP Tutorial 1: Karplus-Strong

The Karplus-Strong Algorithm

据我所知,JOS 是世界领先的合成音生成权威,条条大路通回他的网站。但请注意,它会很快变得棘手,并且需要大学水平的数学。

【讨论】:

  • 看起来很可爱的算法;基本上是一阶 FIR 滤波器和 boxcar 滤波器的组合。我一直在玩弄一些 JavaScript 来使用 FM 合成来制作吉他和弦。弹拨法听起来很有趣,但我想知道它的一致性如何?
【解决方案2】:

你在正确的轨道上。

让我们看看你的例子:

for(int i = 0; i < data.Length; i++)
  data[i] = (byte)(256 * Math.Sin(i));

好的,您每秒有 11025 个样本。你有 60 秒的样本。每个样本都是一个介于 0 和 255 之间的数字,它表示在给定时间空间中某个点的 气压 的微小变化。

不过等一下,正弦从 -1 变为 1,因此样本从 -256 变为 +256,这比一个字节的范围大,所以这里发生了一些愚蠢的事情。让我们重新编写代码,使示例处于正确的范围内。

for(int i = 0; i < data.Length; i++)
  data[i] = (byte)(128 + 127 * Math.Sin(i));

现在我们有了在 1 到 255 之间平滑变化的数据,所以我们在一个字节的范围内。

试一试,看看听起来如何。听起来应该更“流畅”。

人耳可以检测到气压的极其微小的变化。如果这些变化形成重复模式,那么你耳朵中的耳蜗会将模式重复的频率解释为特定音调。压力变化的size被解释为volume

你的波形是六十秒长。变化从最小变化 1 到最大变化 255。峰值在哪里?也就是说,样本在哪里达到或接近 255?

嗯,正弦在 π/2、5π/2、9π/2、13π/2 等处为 1。因此,每当我接近其中之一时,峰值就出现了。也就是说,在 2、8、14、20、...

它们在时间上相距多远?每个样本为 1/11025 秒,因此每个峰值之间的峰值约为 2π/11025 = 约 570 微秒。每秒有多少个峰值? 11025/2π = 1755 赫兹。 (赫兹是频率的量度;每秒有多少个峰值)。 1760 Hz 比 A 440 高两个八度,所以这是一个稍微平坦的 A 音。

和弦是如何工作的?他们是球场的平均值吗?

没有。一个和弦是 A440 和一个八度以上,A880 不等于 660 Hz。你没有平均 音高。你总结 波形

想想气压。如果您有一个振动源以每秒 440 次上下泵送压力,另一个以每秒 880 次上下泵送压力,则净值与每秒 660 次的振动不同。它等于任何给定时间点的压力之和。请记住,WAV 文件就是:气压变化的大列表

假设您想在样本下方制作一个八度音阶。频率是多少?减半。所以让我们让它发生的频率减半:

for(int i = 0; i < data.Length; i++)
  data[i] = (byte)(128 + 127 * Math.Sin(i/2.0)); 

注意它必须是 2.0,而不是 2。我们不想要整数舍入! 2.0 告诉编译器您希望结果是浮点数,而不是整数。

如果你这样做,你会得到一半的峰值:在 i = 4、16、28...,因此音调会低一个八度。 (频率每降低一个八度减半;每升高一个八度加倍。)

尝试一下,看看如何获​​得相同的音调,降低一个八度。

现在将它们加在一起。

for(int i = 0; i < data.Length; i++)
  data[i] = (byte)(128 + 127 * Math.Sin(i)) + 
            (byte)(128 + 127 * Math.Sin(i/2.0)); 

这可能听起来像废话。发生了什么? 我们又溢出了;在许多方面,总和大于 256。 将两个波浪的音量减半

for(int i = 0; i < data.Length; i++)
  data[i] = (byte)(128 + (63 * Math.Sin(i/2.0) + 63 * Math.Sin(i))); 

更好。 "63 sin x + 63 sin y" 介于 -126 和 +126 之间,所以不会溢出一个字节。

(所以一个平均值:我们基本上取的是对每个音调的压力贡献的平均值,而不是频率的平均值.)

如果你演奏,你应该同时得到两个音调,一个比另一个高一个八度。

最后一个表达式复杂且难以阅读。让我们把它分解成更容易阅读的代码。但首先,总结一下到目前为止的故事:

  • 128 介于低压 (0) 和高压 (255) 之间。
  • 音调的音量是波浪所达到的最大压力
  • 音调是给定频率的正弦波
  • 以 Hz 为单位的频率是采样频率 (11025) 除以 2π

让我们把它放在一起:

double sampleFrequency = 11025.0;
double multiplier = 2.0 * Math.PI / sampleFrequency;
int volume = 20;

// initialize the data to "flat", no change in pressure, in the middle:
for(int i = 0; i < data.Length; i++)
  data[i] = 128;

// Add on a change in pressure equal to A440:
for(int i = 0; i < data.Length; i++)
  data[i] = (byte)(data[i] + volume * Math.Sin(i * multiplier * 440.0))); 

// Add on a change in pressure equal to A880:

for(int i = 0; i < data.Length; i++)
  data[i] = (byte)(data[i] + volume * Math.Sin(i * multiplier * 880.0))); 

你去吧;现在你可以产生任何你想要的任何频率和音量的音调。要制作和弦,请将它们加在一起,确保不要太大声而溢出字节。

您如何知道 A220、A440、A880 等以外的音符的频率?每个半音上调前一个频率乘以 2 的 12 次方根。所以计算 2 的 12 次方根,再乘以 440,这就是 A#。将 A# 乘以 2 的 12 次方根,即 B。B 乘以 2 的 12 次方根是 C,然后是 C#,依此类推。这样做 12 次,因为它是 2 的 12 次根,所以你会得到 880,是你开始时的两倍。

当wav文件的内容是波形时,每个音符的播放时间长度如何指定?

只需填写发出声音的样本空间即可。假设你想播放 A440 30 秒,然后播放 A880 30 秒:

// initialize the data to "flat", no change in pressure, in the middle:
for(int i = 0; i < data.Length; i++)
  data[i] = 128;

// Add on a change in pressure equal to A440 for 30 seconds:
for(int i = 0; i < data.Length / 2; i++)
  data[i] = (data[i] + volume * Math.Sin(i * multiplier * 440.0))); 

// Add on a change in pressure equal to A880 for the other 30 seconds:

for(int i = data.Length / 2; i < data.Length; i++)
  data[i] = (byte)(data[i] + volume * Math.Sin(i * multiplier * 880.0))); 

如何将多个音符的结果进行逆 FFT 转换为字节数组,从而构成 wav 文件中的数据?

反向 FFT 只是构建正弦波并将它们相加,就像我们在这里所做的那样。就是这样!

与此相关的任何其他相关信息?

请参阅我关于该主题的文章。

http://blogs.msdn.com/b/ericlippert/archive/tags/music/

第一到第三部分解释了为什么钢琴每个八度有十二个音符。

第四部分与您的问题相关;这就是我们从头开始构建 WAV 文件的地方。

请注意,在我的示例中,我每秒使用 44100 个样本,而不是 11025,并且我使用范围从 -16000 到 +16000 的 16 位样本,而不是范围从 0 到 255 的 8 位样本。但除此之外细节,和你的基本一样。

如果您要制作任何类型的复杂波形,我建议您使用更高的比特率;每秒 11K 样本的 8 位对于复杂的波形来说听起来很糟糕。每个样本 16 位,每秒 44K 样本是 CD 质量。

坦率地说,如果你用有符号的短裤而不是无符号的字节来做正确的数学运算,要容易得多。

第五部分给出了一个关于听觉错觉的有趣例子。

另外,尝试使用 Windows Media Player 中的“范围”可视化来观察您的波形。这将使您对实际发生的事情有一个很好的了解。

更新:

我注意到,当将两个音符附加在一起时,由于两个波形之间的过渡过于尖锐(例如,在一个的顶部结束并从下一个的底部开始),最终可能会出现爆裂声.如何解决这个问题?

很好的后续问题。

基本上这里发生的是从(比如说)高压到低压的瞬时过渡,这被听到为“砰”的一声。有几种方法可以解决这个问题。

技术 1:相移

一种方法是将后续音调“相移”一些小量,以便后续音调的起始值与前一音调的结束值之间的差异。您可以像这样添加相移项:

  data[i] = (data[i] + volume * Math.Sin(phaseshift + i * multiplier * 440.0))); 

如果相移为零,显然没有变化。 2π(或 π 的任何偶数倍)的相移也没有变化,因为 sin 的周期为 2π。 0 到 2π 之间的每个值都会在音调“开始”的位置沿波稍微移动一点。

准确计算出正确的相移可能有点棘手。如果您阅读我关于生成“持续下降”的 Shepard 错觉音调的文章,您会看到我使用了一些简单的微积分来确保一切都在不断变化而没有任何爆音。您可以使用类似的技术来找出使弹出消失的右移是什么。

我正在尝试研究如何生成相移值。 “ArcSin(((新笔记的第一个数据样本)-(上一个笔记的最后一个数据样本))/noteVolume)”对吗?

嗯,首先要意识到的是,可能没有一个“正确的价值”。如果结尾音符非常响亮并在一个峰值处结束,而起始音符非常安静,则新音调中可能没有与旧音调值相匹配的点。

假设有一个解决方案,它是什么?你有一个结束样本,叫它 y,你想找到相移 x 使得

y = v * sin(x + i * freq)

当 i 为零时。那就是

x = arcsin(y / v)

但是,这可能不太对!假设你有

你想追加

两种可能的相移

大胆猜测哪个听起来更好。 :-)

弄清楚您是处于波浪的“上行”还是“下行”可能有点棘手。如果您不想计算出真正的数学,您可以做一些简单的启发式方法,例如“连续数据点之间的差异符号在转换时是否发生了变化?”

技巧 2:ADSR 信封

如果您正在建模听起来像真实乐器的东西,那么您可以通过如下改变音量来获得良好的效果。

您要做的是为每个音符设置四个不同的部分,称为起音、衰减、延音和释音。在乐器上演奏的音符的音量可以这样建模:

     /\
    /  \__________
   /              \
  /                \
   A  D   S       R

音量从零开始。然后攻击发生:声音迅速上升到峰值音量。然后它会稍微衰减到维持水平。然后它保持在那个水平,可能在音符播放时缓慢下降,然后释放回零。

如果您这样做,则不会出现爆音,因为每个音符的开头和结尾都是零音量。该版本确保了这一点。

不同的乐器有不同的“信封”。例如,管风琴的起音、衰减和释放非常短。都是延音,延音是无限的。您现有的代码就像管风琴。与钢琴相比。同样,短起音、短衰减、短释音,但在延音过程中声音确实会逐渐变小。

起音、衰减和释音部分可以很短,短到听不见,但长到足以防止爆音。尝试在音符播放时改变音量,看看会发生什么。

【讨论】:

  • +1 哇,很好的解释!还可以考虑使用 Audacity 查看创建的 wav 文件。您可以在 Audacity 中进行 FFT 以确保频率正确且没有任何谐波(即来自削波)。
  • 非常清晰、深入的解释。感谢您为此付出的所有时间!下次有机会我会试试的:-)
  • @Eric:你碰巧有音乐背景吗?或者这是你的硕士论文? :O
  • @Joan:我在本科时就已经知道从信号到频域的数学转换。多年前,当我买了一架带有挑剔的鲍德温立式动作的旧钢琴时,我对钢琴调音和调节产生了兴趣。我从来没有练习到足够擅长钢琴调音,最终我厌倦了不得不拆开钢琴来修理它,所以我摆脱了它,给自己买了一架廉价的中国制造的全新钢琴。在了解数学和实际学习如何为钢琴调音之间,我已经掌握了足够的理论来回答这个问题。
  • @simonalexander2005:很好的后续问题。我已经发表了一些想法。
【解决方案3】:

你在正确的轨道上。 :)

音频信号

您不需要执行逆 FFT(您可以,但您需要为它找到一个库或实现它,并生成一个信号作为它的输入)。直接从 IFFT 生成我们期望的结果要容易得多,它是具有给定频率的正弦信号。

正弦的参数取决于您要生成的音符和您生成的波形文件的sampling frequency(通常等于 44100Hz,在您的示例中您使用的是 11025Hz)。

对于 1 Hz 音调,您需要一个周期等于一秒的正弦信号。对于 44100 Hz,每秒有 44100 个样本,这意味着我们需要一个周期等于 44100 个样本的正弦信号。由于正弦周期等于Tau (2*Pi) 我们得到:

sin(44100*f) = sin(tau)
44100*f = tau
f = tau / 44100 = 2*pi / 44100

对于 440 Hz,我们得到:

sin(44100*f) = sin(440*tau)
44100*f = 440*tau
f = 440 * tau / 44100 = 440 * 2 * pi / 44100

在 C# 中是这样的:

double toneFreq = 440d;
double f = toneFreq * 2d * Math.PI / 44100d;
for (int i = 0; i<data.Length; i++)
    data[i] = (byte)(128 + 127*Math.Sin(f*i));

注意:我没有对此进行测试以验证代码的正确性。我会尽力做到这一点并纠正任何错误。 更新:我已将代码更新为可以工作的内容。很抱歉伤害了你的耳朵;-)

和弦

和弦是音符的组合(例如参见Minor chord on Wikipedia)。因此,信号将是具有不同频率的正弦波的组合(总和)。

纯音

但这些音调和和弦听起来并不自然,因为传统乐器不会演奏单频音调。相反,当您弹奏 A4 时,频率分布很广,集中在 440 Hz 左右。参见例如Timbre

【讨论】: