Haskell入门教程:7 数字音乐盒

Haskell入门教程:7 数字音乐盒

首页休闲益智合并三重奏更新时间:2024-07-31
本章涵盖

到目前为止,我们一直在处理非常认真的议题。古老的加密方案,最短的路径,CSV文件重整都是严肃的事情。在这一章中,我们希望变得更有趣一点,让我们的创造精神通过制作音乐来自由奔跑!如果您手头没有准备好的乐器或大致了解如何作曲,请不要担心。我们将利用Haskell语言的力量,不仅作为我们自己的音乐合成器,而且还作为作曲工具,轻松创作出观众记住了几个世纪的下一个音乐杰作!

对程序内的真实世界数据进行建模是任何程序员的正常活动。本章将专门教你如何思考抽象现实世界的细节,并将现实世界系统(西方音乐理论系统)的一部分建模为类型和值。每次您想要编写包含某种与软件规范或体系结构没有直接关系的业务逻辑或概念的软件时,都会出现这种建模。Haskell允许高度抽象的建模,完全省略与用例无关的任何细节。这就是我们在本章中试图实现的目标。

为了实现我们的音乐梦想,我们将在我们的程序中对声音进行建模,编写可以即时产生声音的代码。在这样做的过程中,我们还会遇到一些我们必须处理的特定领域问题。使用用于创建声音的函数和类型,我们将围绕它们构建抽象,以促进程序中的合成,而不必担心低级实现。这将让我们思考什么是正确的抽象,以及如何定义处理繁重工作的转换。为了将抽象放在抽象之上,我们将在Haskell中设计我们自己的领域特定语言(DSL)来创作音乐!

7.1 合成多汁的声音

我们必须回答的最重要的问题是:我们的程序如何创建声音?什么是声音?在现实世界的背景下,声音是空气中的振动,使我们的耳膜发痒,在我们的大脑中产生刺激,从而导致听觉刺激现象。对于计算机来说,这稍微简单一些。声音是单声道音频中的信号。当需要两个通道(左和右)时,声音由两个信号组成。什么是信号?在模拟世界中,信号是随时间变化的值,分辨率非常小。我们可以把它看作是一个连续的数学函数,以时间为参数。在数字世界中,我们使用采样来近似模拟信号。样本只是一个数值。这些样本的序列是采样信号。如图7.1所示。

图 7.1.模拟信号采样

从中可以看出,我们重新创建模拟信号的精度受限于采样率(每秒采样信号多少个样本)和分辨率(使用多少位来表示单个样本值)。这两个属性的合理值是什么?多亏了奈奎斯特-香农采样定理,我们知道,当我们想要对已知最高频率的信号进行采样时,我们必须使用至少是最高频率两倍的采样率。对于音频信号,高于 22 kHz 的频率无关紧要,因为我们人类听不到它们(而且大多数音频设备无论如何都无法再现它)。这就是常用的 44.1 kHz 采样率的来源,因为它理论上允许我们完美地采样声音信号。分辨率如何?它主要影响我们信号的信噪比,这意味着有多少期望信号通过量化误差可能产生的背景噪声。对于音乐,表示中使用 8 到 32 位之间的任何内容。我们将在本章中使用的 WAV 文件格式通常在其表示中使用 16 位。

7.1.1 样品样本

在我们对软件中声音制作的探索中,让我们首先关注如何表示我们迄今为止一直在讨论的采样信号。如上所述,单个样本只是一个数值。我们可以使用双精度来表示它。然后,信号只不过是这些样本的列表,即[Double]。这也让我们决定要支持哪种采样率。虽然 44.1 kHz 是标准,但我们将选择更高的 92 kHz。这是性能和音频质量之间的明显权衡。在我们的例子中,性能是次要的,因为我们的合成器不会实时。但是,我们希望避免较高频率的混叠,这可能会使它们听起来跑调。更高的采样率有助于实现这一目标。

我们还可以定义一些其他类型,这样我们就可以更轻松地谈论我们的主题。由于我们必须在某个时候指定频率和持续时间,因此我们可以定义频率 Hz 以及 秒 双倍 .基于此,我们可以创建我们的第一个辅助函数,该函数将告诉我们在特定频率的时间段和特定持续时间内需要多少样本。这将在以后变得很重要。这些类型和函数的代码如清单 7.1 所示。

清单 7.1.用于处理信号的类型和辅助函数

type Sample = Double #1 type Signal = [Sample] #1 type Hz = Double #1 type Seconds = Double #1 sampleRate :: Double #2 sampleRate = 92000 samplesPerPeriod :: Hz -> Int samplesPerPeriod hz = round $ sampleRate / hz #3 samplesPerSecond :: Seconds -> Int samplesPerSecond duration = round $ duration * sampleRate #4

在这里,我们看到了 round 函数的用法。它采用 RealFrac 类型类实例的值,并返回包含 Integral 类型类实例的值。这意味着我们可以采用浮点数或双精度数并将其转换为 Int 或整数 .我们使用它,因为样本量必须是一个整数。不存在一半的样本。在这里,我们立即看到,为什么我们必须担心混叠,因为我们将精确的值舍入为整数。一旦我们理解了一个简单的事实,即采样率是一秒钟内的样本量,就可以理解这两个函数。具有n赫兹的频率的周期正好是1 / n。乘以采样率,得到适合该频率的单个周期所需的样本量。

现在,我们已经涵盖了这些计算,我们可以担心创建声音。在音乐中,我们对声音的音色感兴趣。声音的定性特征...但是在我们进入泛音的兔子洞之前,我们可以简单地走捷径,看看其他合成器是如何做到的。通过使用不同类型的信号波形可以实现不同的声音特性。四种最标准的波形如图7.2所示。

这些波形在其信号中显示单个周期。这些周期的重复取决于信号的长度和我们尝试生成的频率。如果时间是固定的,频率的变化会拉伸或挤压信号。samplesPerPeriod 和 samplesPerSecond 可用于计算在给定频率的时间段内我们需要多少个样本。重复这些样本直到整个持续时间被填满,将在一定的时间内为我们提供一定频率的音调。改变这些频率会产生音乐!就我们的目的而言,示例值的范围从 -1 到 1。当我们想要控制信号的音量时,我们会担心稍后会调制它。现在,我们应该实现波形。

图 7.2.四种标准波形

在我们的例子中,波只是一个函数,它接收 0 到 1 之间的数值参数,并返回该点波形中位置的采样值。然后,我们可以使用此参数来“扫描”波形。我们扫描得越快(意味着单个时间步之间的距离越大),周期就越短,频率就越高。由于图 7.2 中的波形只是数学函数,因此我们可以在代码中对它们进行建模。正弦波可以用前奏曲中存在的正弦函数生成。方波是输入参数的大小写区分,当输入小于或等于 1.0 时,则为 -5,否则为 1。锯齿波和三角波需要更多的思考,因为我们必须创建斜坡。对于输入 t 偏移 -2 的锯齿波是 1 * t 的斜率。对于三角波,我们必须创建两个斜率。前半部分是锯齿,其长度正好减半。所以它可以计算为函数 4 * t - 1 。在后半部分,计算的符号切换,因为斜率需要镜像。为了适应这一点,我们将偏移量变为 3 以创建 1 到 -1 之间的值 .如清单 7.2 所示。

清单 7.2.用于处理信号的类型和辅助函数

type Wave = Double -> Sample #1 sin :: Wave sin t = Prelude.sin $ 2 * pi * t #2 sqw :: Wave sqw t | t <= 0.5 = -1 #3 | otherwise = 1 #3 saw :: Wave saw t | t < 0 = -1 #4 | t > 1 = 1 #4 | otherwise = (2 * t) - 1 #4 tri :: Wave tri t | t < 0 = -1 #5 | t > 1 = -1 #5 | t < 0.5 = 4 * t - 1 #5 | otherwise = -4 * t 3 #5

所有这些函数仅在输入介于 0 和 1 之间的输入时才有效。对于超出此范围的值,函数变为常量。就我们的目的而言,函数在其有效输入范围之外做什么并不重要。

我们现在可以使用这些函数来计算构建一个用于创建静音的函数(这只是一个具有恒定 0 值的信号)和一种音调所需的样本量,该函数将使用我们的波形之一在一定时间内产生特定频率。为此,我们计算需要生成多少样本,并将它们从 0 枚举到样本数。然后,我们将这些值除以将周期处于正确频率所需的样本数模化。这将创建一个输入参数列表,然后我们可以将其插入到我们想要的任何 Wave 函数中。由此产生的信号是我们的语气。代码如示例 7.3 所示。

清单 7.3.从波形产生信号的功能

silence :: Seconds -> Signal silence t = replicate (samplesPerSecond t) 0 #1 tone :: Wave -> Hz -> Seconds -> Signal tone wave freq t = map wave periodValues #2 where numSamples = samplesPerPeriod freq #3 periodValues = #4 map (\x -> fromIntegral (x `mod` numSamples) / fromIntegral numSamples) [0 .. samplesPerSecond t]

同样,我们必须使用 fromIntegral 来划分浮点数而不是整数。在此函数中,我们手动确保波函数在正确的持续时间内提供重复值。这似乎有些复杂,因为我们只需计算一次波,然后重复它。请留意稍后的练习,您将有机会实现这一点!

锻炼

对于我们的波形,我们实现了方波。方波的特殊之处在于,它实际上是一种所谓的脉冲波,占空比为50%,这意味着信号“低”的时间量等于信号“高”的时间量。脉冲波可以定义为任何占空比,当它们用于音乐时,声音特性实际上会随着占空比而变化!实现具有可调占空比的通用脉冲波形。

最后,我们终于可以使用这些功能来创建真实的声音了!为此,我们将使用一个名为HCodecs的库,该库能够读取和写入WAV文件,这是大多数音频播放器可以播放的未压缩音频格式。为了使这个库可供我们使用,我们在package.yml文件的依赖项部分添加了HCodecs。该库具有一个名为 导出文件 ,它允许我们将音频数据写入 WAV 文件。

为此,我们必须构造一个 Audio 值,该值是由采样率、采样数据和通道号信息组成的记录。要构造这个值,我们首先必须将我们的信号转换为库调用 SampleData 的东西,该库在内部使用数组来存储音频数据。数组存在于Haskell中,但本书不会涉及,这就是为什么我们不会深入探讨它们。对我们来说,重要的是要知道我们需要将数组添加到依赖项中才能使用它们。然后我们可以使用 listArray 函数从列表构造一个数组,该列表是一个接收元组作为参数的函数,指定数组的边界和我们尝试转换的列表中的数据。这里需要确保元组指定的范围从 0 到列表长度减一。要将我们的样本类型转换为 HCodecs 可以理解的样本,我们可以使用 fromSample 函数。所有这些的完整代码如清单 7.4 所示。

清单 7.4.将音频数据写入WAV文件的函数

import Data.Array.Unboxed (listArray) #1 import Data.Audio #2 import Data.Int (Int16) #3 signalToSampleData :: Signal -> SampleData Int16 signalToSampleData signal = listArray (0, n) $ map fromSample signal #4 where n = length signal - 1

将信号转换为 SampleData 时,我们需要确保我们的值在 -1 到 1 之间,我们可以通过限制值的函数来确保这一点。但是,我们永远不想达到这些值,因此我们应该通过一个小因素来抑制有限的信号。通过使用最小值和最大值,我们可以将值限制为阈值。实现此目的的函数如清单 7.5 所示。

清单 7.5.箝位信号值的功能

limit :: Signal -> Signal limit = map (min threshold . max (-threshold) . (* threshold)) #1 where threshold = 0.9

在这里,我们看到了映射函数内部三个操作的组成。首先将映射的值乘以阈值,然后执行最大和最小钳位。

现在,我们可以将这些函数放在一起,以创建一个 IO 操作,该操作使用 HCodecs 库中的类型和函数将我们的信号写入 WAV 文件。其代码如示例 7.6 所示。

清单 7.6.将音频数据写入WAV文件的函数

import qualified Codec.Wav #1 import Data.Audio #1 writeWav :: FilePath -> Signal -> IO () writeWav filePath signal = do putStrLn $ "Writing " show (length signal) " samples to " filePath let sampleData = signalToSampleData $ limit signal #2 audio = Audio #3 { Data.Audio.sampleRate = round Util.Types.sampleRate, channelNumber = 1, sampleData = sampleData } Codec.Wav.exportFile filePath audio #4

我们可以使用这些函数最终创建一个 WAV 文件。

ghci> writeWav "4waves.wav" $ concatMap (\w -> tone w 220 5) [Sound.Synth.sin, tri, saw, sqw]

这将创建一个名为 4waves 的文件.wav其中 20 秒的音频分成 4 个不同的波形,每个波形以 220 Hz 播放 5 秒。由于我们的信号只是一个简单的列表,我们可以使用 ( ) 、concat 或 concatMap 等函数来附加信号。

我们可以使用像 Audacity 这样的免费音频编辑器检查我们的声音文件来检查我们创建的信号。如图7.3所示。

图 7.3.导出的音频文件的波形

使用音频播放器(或像 Audacity 这样的编辑器),我们也可以收听文件。但是,到目前为止,这听起来并不令人愉快。

7.1.2 回到ADSR

接下来,我们想给我们的色调一点轮廓。正如您在使用我们新创建的音调发生器时可能听到的那样,当音调停止时,声音有时会“咔嗒”一声。这是因为当达到所需的持续时间时,我们的周期函数可能会突然中断。如图7.4所示。

图 7.4.波形突然变化导致音频“咔嗒”的示例

我们需要一些方法来勾勒音调,尤其是信号的结尾,以摆脱这种点击。在大多数合成器中,这是通过包络实现的,该包络塑造信号的幅度,通常逐渐变细开始和结束。对于我们的合成器,我们希望实现非常常见的ADSR包络。ADSR 代表 攻击衰减维持释放。这个包络让信号上升一段时间(攻击),然后需要一定的时间(衰减)将信号逐渐减少到固定值(持续),然后让信号慢慢淡出(释放)。这种包络如图7.5所示,显示了包络如何影响信号。

图 7.5.ADSR 包络及其对信号的影响

这就提出了一个问题:我们如何表示ADSR包络?由于包络需要影响信号的幅度,我们可以将其理解为我们想要将信号乘以的因素列表,如图7.5所示。所以同样,解决方案是一个简单的 [双倍] .

为了表示信封的参数,我们可以使用记录。攻击、衰变和释放都是持续时间。它们的曲线都是线性的,即使在其他合成器中它们也不必是线性的。但是,维持是保持的水平。保持此电平的持续时间由被调制信号的持续时间和其他参数的长度决定。这种信封参数的代码如清单 7.7 所示。

清单 7.7.表示 ADRS 信封参数的类型

data ADSR = ADSR #1 { attack :: Seconds, #2 decay :: Seconds, #2 sustain :: Double, #2 release :: Seconds #2 } deriving (Show) #3

现在,我们可以考虑如何将这些参数应用于信号。由于我们必须为样本生成一个介于 0 和 1 之间的因子列表,因此我们可以使用众所周知的列表操作。

我们想要生成的线性函数可以用列表表达式计算,将每个值除以最大值。

ghci> map (/ 10) [1..10] [0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0]

这已经是我们的攻击曲线。对于衰减,我们希望实现类似的东西,但创建从 1 到指定值的线性下降。

ghci> s = 0.5 ghci> map (\x -> (x / 10) * (1 - s) s) [1..10] [0.55,0.6,0.65,0.7,0.75,0.8,0.85,0.9,0.95,1.0]

当我们反转这个列表时,我们已经实现了衰变!但是,我们需要确保我们可以将其添加到攻击中。由于攻击已经包含最大值 1,我们不希望在衰减中重复它。我们可以通过将基本列表从 0 开始并计数到所需的长度减 1 来解决此问题。

ghci> map (\x -> (x / 10) * (1 - s) s) [0..9] [0.5,0.55,0.6,0.65,0.7,0.75,0.8,0.85,0.9,0.95]

在这里,我们构建了一个衰减,逐渐减小到0.5的持续值。说到这一点,由于 sustain 只是一个固定值,我们可以使用复制来生成它的列表。但是,我们需要计算我们需要多少值,这应该由仍然需要添加的发布样本的数量来确定。我们可以重用我们用来构建攻击曲线的逻辑来构建释放曲线,然后以这种方式修改维持。但是我们如何才能将这些曲线结合起来呢?

回想一下我们在列表中使用的一些函数,我们可以想到zipWith,它将两个列表与给定的函数组合在一起,停在较短的列表上。此外,zipWith3 适用于三个列表而不是两个列表。我们可以将攻击、衰减和维持视为一个列表,将释放曲线视为另一个列表。这些必须与信号相结合。现在我们只需要建立发布曲线。虽然我们可以对信号的长度以及每个部分需要多少值进行大量计算,但我们可以通过使用无限列表来简化过程。

哈斯克尔懒惰有一个有趣的方面,我们可以创建无限的定义,但不能无限评估。列表的定义可以是无限递归的,而不会引起问题,因为函数不一定需要计算所有(无限多个)值。

ghci> ones = 1 : ones ghci> take 10 ones [1,1,1,1,1,1,1,1,1,1]

只要我们使用不计算整个列表的函数,如take,takeWhile ,(!!),head等,我们就可以愉快地使用这些列表,甚至映射,过滤或压缩它们。这也扩展到我们可以像图形一样无限生成的其他数据类型。

对于列表,我们可以使用熟悉的范围表达式通过简单地省略最大值来轻松创建无限列表。这样,用非常简洁的表达式生成全自然数或全奇数几乎是微不足道的。

ghci> take 10 [1..] [1,2,3,4,5,6,7,8,9,10] ghci> take 10 [1,3..] [1,3,5,7,9,11,13,15,17,19]谨慎

无限的数据结构很酷,但很危险。应非常小心,以确保没有调用任何试图完全评估它们的函数。一旦我们无法确定我们的数据是有限的,我们为自己制定的处理数据的规则就会可悲地崩溃。无限列表上的调用长度将永远运行,从而导致我们的程序无限期挂起 最简单的方法是简单地避免它们!

对于我们的用法,我们希望重复一些固定值(1表示释放曲线的开始和维持水平),这可以通过恰当命名的重复函数来实现,该函数返回具有单个值的无限列表。

ghci> take 10 $ repeat 1 [1,1,1,1,1,1,1,1,1,1]

我们可以利用这样一个事实,即zipWith停止在较短的列表上,以组合无限列表和有限列表。

ghci> zipWith ( ) [1,2,3] (repeat 1) [2,3,4]

回到包络的用例,我们可以使用一个列表来组合攻击和衰减曲线,然后无限重复持续水平。其次,我们可以构建与攻击固化相同的释放曲线并重复 1,从该曲线中获取与信号所需的元素数量一样多并反转它。将这些曲线与原始信号相乘将产生我们想要的结果。

锻炼

就像 repeat 创建具有单个值的无限列表一样,循环函数将列表作为参数,并无限循环遍历该列表的值。在我们的音调函数中,我们使用 mod 手动计算循环波形。现在,使用循环和采取重新实现音调功能,通过计算一次波形,然后循环遍历它。

此信封的实现如清单 7.8 所示。该函数计算曲线并将其应用于作为参数给出的信号。zipWith3的使用确保整个信号都会受到影响,因为延音是无限长的,释放的时间和信号一样长。因此,该函数的正确性由构造给出。

清单 7.8.将 ADSR 包络应用于信号的函数

adsr :: ADSR -> Signal -> Signal adsr (ADSR a d s r) signal = zipWith3 #1 (\adsCurve rCurve sample -> adsCurve * rCurve * sample) #1 (att dec sus) #2 rel signal where attackSamples = fromIntegral $ samplesPerSecond a #3 decaySamples = fromIntegral $ samplesPerSecond d #3 releaseSamples = fromIntegral $ samplesPerSecond r #3 att = map (/ attackSamples) [0.0 .. attackSamples] #4 dec = reverse $ #5 map #6 (\x -> ((x / decaySamples) * (1 - s)) s) #6 [0.0 .. decaySamples - 1] #6 sus = repeat s #7 rel = reverse $ #8 take #9 (length signal) #9 (map (/ releaseSamples) [0.0 .. releaseSamples] repeat 1.0) #10

现在我们可以将此信封应用于我们的音调,并第一次听到轮廓。

ghci> params = ADSR 0.1 0.2 0.5 0.1 ghci> writeWav "adsrSignal.wav" $ adsr params (tone tri 550 1)

当我们在音频编辑器中打开生成的文件时,我们可以看到轮廓。线性攻击曲线需要 100 毫秒才能达到其最大音量。然后,信号在200毫秒内线性篡改至持续电平,即半音量(0.5)。然后,释放曲线在 0 毫秒内将音量减小到 100。如图7.6所示。

图 7.6.应用了 ADSR 包络的信号

有了这个信封,我们可以更大幅度地塑造色调。我们可以通过短暂的起音、衰减和释放以及将持续水平保持在非常低的水平来创造“弹跳”的声音。我们还可以通过保持更长的攻击和衰减来产生“膨胀”的声音。

随着音调生成和轮廓的处理,我们现在可以在合成器世界中构建自己的噪声制造器,称为振荡器

7.1.3 我说的是“回旋振荡”

合成器必须完成一项工作。获取控制信号(来自键盘、电子信号或数字串行端口)并产生适当的声音。我们也有兴趣控制我们的噪音。我们特别希望控制我们生成的音调的音高持续时间时刻,以创作音乐。为此,我们为音调发生器编码事件。事件可以是在特定时刻以指定频率在特定持续时间内播放的音调,也可以是具有定义的开始和持续时间的静音。我们使用记录语法将这些事件编码为类型。然后,我们可以定义一些帮助程序函数来区分音调和静音,并计算事件的结束时间。其代码如清单 7.9 所示。

清单 7.9.将 ADSR 包络应用于信号的函数

data Event = Tone {freq :: Hz, start :: Seconds, duration :: Seconds} #1 | Silence {start :: Seconds, duration :: Seconds} #2 deriving (Show) #3 isTone :: Event -> Bool #4 isTone Tone {} = True isTone _ = False isSilence :: Event -> Bool #5 isSilence Silence {} = True isSilence _ = False end :: Event -> Seconds #6 end e = start e duration e

这种类型中值得注意的是字段的使用。正如我们在第 5 章中看到的,记录语法将自动从记录的指定字段创建函数,为我们检索该字段的值。当我们的类型中有多个使用记录语法的构造函数时,也是如此。

ghci> :t start start :: Event -> Seconds ghci> :t duration duration :: Event -> Seconds ghci> :t freq freq :: Event -> Hz

如果一个字段由多个构造函数共享,则生成的函数(称为字段选择器)足够智能,仍然可以匹配正确的字段。这就是为什么开始和持续时间的使用对于结束函数的定义是可以的。

ghci> start $ Tone 0 1 2 1.0 ghci> start $ Silence 1 2 1.0

但是,频率是特殊的,因为它不会出现在所有构造函数中 这使它成为部分字段选择器

ghci> freq $ Tone 440 1 2 440.0 ghci> freq $ Silence 1 2 *** Exception: No match in record selector freq

本质上,部分字段选择器是部分函数,这意味着它们未针对某些值定义。这使得它们使用起来很危险,应格外小心避免使用它们。

注意

使用 -Wpartial-field 编译标志 GHC 可以在出现部分字段选择器时自动警告您。在编译过程中激活这样的警告总是好的。有时,您可能不想向代码中添加部分字段,但意外地通过重构另一条记录的字段来执行此操作,然后警告可以为您捕获!

现在我们有了事件的定义,我们可以担心创建基于它们产生信号的组件。在我们的代码中,我们希望称它们为振荡器,因为它们与音乐合成器中的振荡器相连。在大多数情况下,这些振荡器具有预先选择的波形,并直接连接到一些包络上进行轮廓绘制。振荡器的类型是一个简单的 newtype,它将函数 Event -> Signal 包装成一个新类型。然后,我们可以构造一个函数,该函数结合了波函数和 ADSR 参数,以创建一个从事件生成音调的振荡器。其代码如示例 7.10 所示。

清单 7.10.用于定义振荡器的函数

newtype Oscillator = Osc {playEvent :: Event -> Signal} #1 osc :: Wave -> ADSR -> Oscillator osc wave adsrParams = Osc oscf #2 where oscf (Silence _ t) = silence t #3 oscf (Tone f _ t) = adsr adsrParams $ tone wave f t #4

如我们所见,我们忽略了事件的开始参数。这是因为我们不想担心信号在振荡器中是如何组合的。最后,我们的合成器可以有多个振荡器,这些振荡器有时在完全不同的时间播放它们的音调,有时在重叠的时间播放它们的音调。为了正确处理这个问题,我们稍后需要做一些内务处理,但现在我们可以开始构建不同的振荡器,它们自己的特性由其基础波和 ADSR 参数决定。在示例 7.11 中,我们可以看到噪声制造者的三个候选者都有自己的特征。您可以使用使用的波形和ADSR参数来创建自己独特的音乐机器。

清单 7.11.用于定义振荡器的函数

piano :: Oscillator piano = osc saw $ ADSR 0.01 0.1 0.2 0.2 #1 ocarina :: Oscillator ocarina = osc sin $ ADSR 0.01 0.3 0.7 0.01 #2 violin :: Oscillator violin = osc saw $ ADSR 2 2 0.1 0.2 #3 pluck :: Oscillator pluck = osc sqw $ ADSR 0.01 0.05 0 0.01 #4 bass :: Oscillator bass = osc tri $ ADSR 0.001 0.2 0.9 0.1 #5

使用我们的振荡器类型的字段选择器,我们可以亲自尝试这些振荡器。

ghci> oscs = [piano, ocarina, violin, pluck, bass] ghci> signal = concatMap (`playEvent` (Tone 440 0 1)) oscs ghci> writeWav "oscillators.wav" signal

由此产生的信号向我们展示了在切换波形或声音轮廓时发生的声音剧烈变化。我们在合成器的声音生成部分的工作到此结束。

锻炼

我们已经为声音生成创建了基本功能集。现实世界中的合成器在声音塑造、实现滤波器和其他类型的包络方面提供了更大的灵活性。通过增加调制的可能性来扩展我们的振荡器功能,这意味着另一个信号(处于 0-10 Hz 的低频)会随着时间的推移影响振荡器的某些特性。当我们影响信号的幅度时,称为颤音效应。当我们定期改变生成的音调的频率时,它被称为颤音。实现这两种效果,使我们的合成器更深入一些。

随着我们的噪音制造者准备就绪,我们可以开始担心作曲,如何建模和分组音符,然后如何演奏这些作品。

7.2 音符模型

当我们想要创作音乐作品时,我们需要一种方法来根据某些全局时间测量对音高和持续时间进行分类。本章不会变成一个关于音乐理论的无聊讲座,这就是为什么我们大多会跳过这些细节,但音乐理论建模的实施对我们来说应该是有趣的。

7.2.1 投球思路

首先让我们解决音高问题。我们已经有一种方法可以量化我们的振荡器类型的音高,即Hz,信号的频率。然而,贝多芬和莫扎特不是用频率作曲,而是用音符作曲。这些与我们的振荡器可以创建的内容之间有什么关系?这就是我们现在要弄清楚的。

为了建立关系,我们已经知道在某些时候我们需要将音符转换为频率。为此,我们可以创建一个类型类。此类如清单 7.12 所示。

清单 7.12.可转换为频率的类型类

class Pitchable a where #1 toFrequency :: a -> Hz instance Pitchable Hz where toFrequency = id #2

该类包含一个函数,该函数可以将类型转换为频率。已经给出了这个类的第一个实例,它是针对 Hz 本身的。当然,频率可以通过做...什么都没有,这就是为什么在这种情况下,toFrequency 的实现是恒等函数。

在键盘(钢琴类型,而不是连接到计算机的键盘)上,音符由半音隔开。如图7.7所示。将手指从一个键移动到下一个键会将演奏的音符更改为一个半音。为这些半音创建一个类型是有意义的,但是该类型应该是什么样子的?

图 7.7.与音乐会 A 有半音距离的音乐键盘

今天播放的大多数音乐都围绕着相同的调音。此调谐将音乐会 A 定义为 440 Hz。当我们将半音定义为与音乐会 A 的距离(以半音为单位)时,我们可以使用图 7.8 中方便的小公式来计算音符的频率。

图 7.8.计算从给定半音距离到十二音相等气质的音乐会A(s)的频率(f)的公式

这导致我们为合成器定义半音。半音只是一个整数,指定到音乐会 A 的一定距离。创建可音高半音实例所需的计算如图 7.8 所示。其代码如示例 7.13 所示。

示例 7.13.半音的类型

{-# LANGUAGE TypeSynonymInstances #-} #1 type Semitone = Integer #2 instance Pitchable Semitone where toFrequency semitone = 440 * (2.0 ** (fromInteger semitone / 12)) #3

现在我们可以尝试计算了。预计一旦我们从特定半音上升或降低一个八度,频率应该增加一倍或一半。

ghci> toFrequency (0 :: Semitone) 440.0 ghci> toFrequency (12 :: Semitone) 880.0 ghci> toFrequency (-12 :: Semitone) 220.0 ghci> toFrequency (5 :: Semitone) 587.3295358348151 ghci> toFrequency (5 12 :: Semitone) / 2 587.3295358348151

现在,我们有了指定音调及其音高关系的第一个想法。然而,贝多芬和莫扎特也没有用半音作曲,他们使用音符。现在是我们解决最后一个问题的时候了。

锻炼

因此,从给定的半音中添加或减少 12 个半音应该会导致频率增加一倍或一半吗?这听起来很像一个正式的财产!为半音的频率计算编写一个快速检查测试。构建此测试时会出现什么问题?我们如何解决它?

这里有一个小秘密:音符只不过是半音的花哨名称。音乐会A也称为A4。“A”是八度中半音的名称,“4”指定我们所处的八度。八度和半音的数量是有限的,因为人类的听力被限制在大约 20 Hz 到 20 kHz 之间。低于 20 Hz 的所有内容听起来都不像音高,而更像是一种节奏,大多数超过 20 kHz 的频率只能被我们的宠物接收,而不能被我们拾取。

回到笔记的话题。键盘上八度的半音被分解为音符名称。按半音间隔分解的音符也称为半音。这些名称如图7.9所示。

图 7.9.带有音符名称的音乐键盘

我们应该如何表示这些?理论上,我们可以将所有可能的值枚举为一个巨大的总和类型,如下所示:

data Chromatic = C0 | Cs0 | D0 | Ds0 | E0 ... | C1 | Cs1 | D1 ... ... | C8 | Cs8 ...

然而,这不仅是写下来的痛苦,而且是模式匹配的痛苦,这就是为什么我们要拆分一个音符处于哪个八度以及它与该八度的底部具有哪个半音偏移的信息(由音符名称给出)。因此,将半音符名称和实际半音符的数据类型分开是有意义的。对于与彩色音符相关的数字,允许负数确实没有意义,这就是为什么我们要使用来自 Numeric.Natural 的自然类型。此类型编码自然数,表示非负整数。新数据类型的代码如示例 7.14 所示。

清单 7.14.半音符的类型

import Numeric.Natural (Natural) #1 data ChromaticName = A | As | B | C | Cs | D | Ds | E | F | Fs | G | Gs #2 deriving (Read, Show, Eq) #3 data Chromatic = Chromatic ChromaticName Natural #4 deriving (Show, Eq) #5

值得注意的是为ChromaticName类型派生的类型类实例,它们是读取,显示和方程。我们之前在第 5 章中遇到过 Read,当时我们使用 readMay 函数将文本解析为代码中的数字。让我们简要回顾一下此类型类的功能。

7.2.2 阅读我们展示的内容

我们知道 Show 是一个可以将 Haskell 值转换为字符串的类型类,但是什么样的字符串呢?一般来说,它们应该是 Haskell 值本身的字符串表示形式,这意味着你可以(理论上)将此字符串转换回你在 Haskell 中的值。因此,如果 show 是函数将值转换为字符串,哪个函数将其转换回 Haskell 值?这个神奇的功能叫做 读 .

ghci> :i read read :: Read a => String -> a ghci> read "100" :: Int 100 ghci> read "True" :: Bool True ghci> read "[1,2,3]" :: [Int] [1,2,3]

如果我们想从字符串中读取我们的值,我们只需要 Read 类型类的一个实例。幸运的是,我们可以为仅包含值的数据类型派生此实例,而这些值又具有读取实例。

ghci> data RS = A Int | B Float deriving (Read, Show) ghci> read "A 100" :: RS A 100 ghci> read "B 3.1415" :: RS B 3.1415 ghci> read . show $ A 100 :: RS A 100

一般来说,阅读是节目的双重。这意味着阅读.show 应等效于 id 。读取和显示的派生实例遵循此定律。它甚至适用于递归数据类型。

ghci> data A = A A | B Int deriving (Read, Show) ghci> read "A (A (A (B 1)))" :: A A (A (A (B 1)))

当我们创建这些类型类的自己的实例时,如果我们不关心序列化和解析我们的值,我们可能会偏离此规则。

现在,我们可以回到派生的“显示”和“读取”实例的色度类型。让我们看看它们的实际效果:

ghci> :t A A :: ChromaticName ghci> :t A A :: ChromaticName ghci> show A "A" ghci> read "A" :: ChromaticName A

使用 read,我们可以解析名称!由于自然也有一个读取的实例,我们也可以从文本中读取自然数。这使我们能够为 Chromatic 类型的 IsString 类型类(在第 5 章中首次出现)创建一个实例。其代码如示例 7.15 所示。

清单 7.15.半音符的类型

instance Show Chromatic where show (Chromatic name oct) = show name show oct #1 instance IsString Chromatic where fromString s = Chromatic (read $ init s) (read [last s]) #2

通过此实现和 OverloadString 语言扩展的使用,我们现在可以轻松写下我们的半音符。我们在这里创建自己的 show 实现,以在 Show 和 IsString 之间创建对偶,而不是 Show 和 Read 。我们这样做是因为首先,我们没有这种类型的 Read 实例,其次我们希望专注于尽可能轻松地写下和理解值。

ghci> "A4" :: Chromatic A4

现在,最后一步是将这些音符转换为频率。由于它们只是半音的花哨名称,并且我们已经知道如何从半音计算频率,因此我们的最后一项任务是转换这些半音的半音符。我们知道 A4 必须是 0,并且与 A 的偏差等于单个半音,我们可以通过将八度音符的偏移量加上八度数减四乘以 12 来计算半音(因为一个八度中有 12 个半音)。可以简单地枚举偏移量。使用它,我们还可以计算频率。如清单 7.16 所示。

清单 7.16.从半音符到半音和频率的转换

chromaticToSemitone :: Chromatic -> Semitone chromaticToSemitone (Chromatic name oct) = (12 * (fromIntegral oct - 4)) noteOffset name #1 where noteOffset C = -9 #2 noteOffset Cs = -8 #2 noteOffset D = -7 #2 noteOffset Ds = -6 #2 noteOffset E = -5 #2 noteOffset F = -4 #2 noteOffset Fs = -3 #2 noteOffset G = -2 #2 noteOffset Gs = -1 #2 noteOffset A = 0 #2 noteOffset As = 1 #2 noteOffset B = 2 #2 instance Pitchable Chromatic where toFrequency = toFrequency . chromaticToSemitone #3

同样,我们可以检查我们的频率和八度属性是否适用于这种新类型。

ghci> toFrequency ("A4" :: Chromatic) 440.0 ghci> toFrequency ("A5" :: Chromatic) 880.0 ghci> toFrequency ("A3" :: Chromatic) 220.0 ghci> toFrequency ("C2" :: Chromatic) 65.40639132514966 ghci> toFrequency ("C3" :: Chromatic) / 2 65.40639132514966

有了这些类型作为我们的基石,我们可以创建我们的第一个小旋律。

ghci> melody = map toFrequency ["C4", "E4", "G4", "D5", "C5" :: Chromatic] ghci> signal = concatMap (\f -> playEvent piano $ Tone f 0 0.7) melody ghci> writeWav "melody.wav" signal

这很好,但听起来有些单调。所有音符的演奏时间相同。我们可以改变每个音符的持续时间,但我们没有很好的方法来写下来。

锻炼

通过实现任意半音实例为新类型创建新的快速检查属性,并检查到半音和 Hz 的转换是否正确工作。

我们在作文中需要的是一种谈论音符长度的方式,我们接下来将要解决。

7.2.3 自然音符长度

音符按比例分类。比率指定音符占用的柱线时间。整个音符占据整个小节,半个音符占据一半,四分之一音符占据四分之一,依此类推。此外,比率始终是正的,因为我们没有“负时间”的概念。那么,我们如何在代码中表示它们呢?

虽然我们可以使用 Double 或 Float 来表示比率,但我们可以通过使用 Data.Ratio 中的 Ratio 类型来更明确。比率正是您所期望的:分子和分母。这样,如果基础数值类型可以无限大,则可以以任意精度表示比率。此外,Ratio 实现了 Num 类型类,因此我们可以使用这些比率进行计算。

与 Ratio 一起使用的主要运算符是 (%) 来指定带有分子和分母的有理数。

ghci> import Data.Ratio ghci> :k Ratio Ratio :: * -> * ghci> 1 % 2 :: Ratio Int 1 % 2 ghci> 3 % 2 1 % 2 :: Ratio Int 2 % 1 ghci> 4 % 5 - 2 % 10 :: Ratio Int 3 % 5

正如我们所看到的,比率的类型是 * -> * ,因此它由另一种类型参数化。(%) 运算符确保只能使用整数类型。所以你可以构造比率Int和比率整数,但不能构造比率双精度。

ghci> :t (%) (%) :: Integral a => a -> a -> Ratio a

Ratio Integer在Haskell中也被称为Rational。

ghci> :i Rational type Rational :: * type Rational = Ratio Integer

出于我们的目的,我们希望在类型级别上禁止负值,因此使用 Natural 是有意义的,幸运的是,它还有一个 Integral 类型类的实例。利用这一点,我们已经可以构建许多已知的西方音乐理论的音符长度。

重要

虽然当我们想要用非负数值表示值时,自然似乎是一个不错的选择,但情况并非总是如此。当使用自然数的计算变为负值时(这是可能的,因为存在实例 Num Natural,因此您可以减去它们),将引发异常。这可能会导致程序在意外时间崩溃!

注释长度的类型以及一些常量如清单 7.17 所示。

清单 7.17.从半音符到半音和频率的转换

import Data.Ratio (Ratio, (%)) #1 import Numeric.Natural (Natural) #2 type Notelength = Ratio Natural #3 whole :: Notelength #4 whole = 1 % 1 half :: Notelength #4 half = 1 % 2 quarter :: Notelength #4 quarter = 1 % 4 eighth :: Notelength #4 eighth = 1 % 8 sixteenth :: Notelength #4 sixteenth = 1 % 16

然而,西方音乐记谱法中也存在音符长度的修饰符。我们感兴趣的是虚线音符元组。虚线音符可以理解为原始音符的伸长。如果注释是虚线,则其持续时间将增加原始长度的一半。这也可以多次完成,因此一个笔记可以点缀两次甚至三次。三胞胎非常有趣,因为它们允许我们创造超出我们正常比例的不规则节奏。对于写成三重奏的音符,它们以不同的方式细分节拍。它们也由一些常数参数化。非常常见的是三胞胎是三个,五元组是五个。音符持续时间在三胞胎值上乘以 2,因此对于三胞胎,其二比三,五胞胎为二大于五。

虽然人类对音符长度的复杂性有一些限制,但他们仍然可以解释和执行我们的合成器不受这种微薄的生物限制的阻碍。因此,我们将允许任意点和元组。我们可以将这些修饰符实现为示例 7.18 中所示的简单函数。

清单 7.18.从半音符到半音和频率的转换

dots :: Natural -> Notelength -> Notelength dots n x = x x * (1 % 2 ^ n) dotted :: Notelength -> Notelength dotted = dots 1 doubleDotted :: Notelength -> Notelength doubleDotted = dots 2 tripleDotted :: Notelength -> Notelength tripleDotted = dots 3 tuplet :: Natural -> Notelength -> Notelength tuplet n x = x * (2 % n) triplet :: Notelength -> Notelength triplet = tuplet 3 quintuplet :: Notelength -> Notelength quintuplet = tuplet 5

现在我们可以修改音符长度,这将使我们能够在以后创建更有趣的节奏模式。

ghci> nl = 1 % 2 :: Notelength ghci> dotted nl 3 % 4 ghci> triplet nl 1 % 3

清单 7.18 中需要注意的可能是 (^) 运算符的用法。以前我们使用 (**) 来计算频率,但现在我们使用另一个运算符,即使两者都做幂运算。为了使事情更加混乱,还有第三个用于幂的运算符,它看起来有点像表情符号:(^^)。这是怎么回事?这些运算符中的每一个都有自己的实现小怪癖。首先,让我们收集一些有关其类型的信息。

ghci> :t (**) (**) :: Floating a => a -> a -> a ghci> :t (^) (^) :: (Num a, Integral b) => a -> b -> a ghci> :t (^^) (^^) :: (Fractional a, Integral b) => a -> b -> a

仅从签名中我们就可以看到,当指数是浮点数时,必须使用 (**)。(^) 并且可以(^^)与整数指数一起使用,但(^^)只能与小数基一起使用。然而,它们最大的区别在于它们的精度和指数允许的值。(^) 不允许负指数。对于积分指数 (^),(^^)在精度方面是首选,但不一定是负指数。

ghci> 1.2 ** 5.1 :: Double 2.534103535654163 ghci> 1.2 ** 5 :: Double 2.4883199999999994 ghci> 1.2 ^ 5 :: Double 2.48832 ghci> 1.2 ^^ 5 :: Double 2.48832 ghci> 1.2 ^ (-5) :: Double *** Exception: Negative exponent ghci> 1.2 ^^ (-5) :: Double 0.4018775720164609 ghci> 1.2 ** (-5) :: Double 0.40187757201646096

如您所见,它很快就会变得非常混乱,并且操作员的正确选择并不总是显而易见的。一般来说,我们使用 (^) 表示整数、(^^)有理值和 (**) 表示浮点数,但当负指数发挥作用时,例外仍然适用。

7.2.4 我的节奏

现在,我们已经很好地掌握了音符长度,我们必须采取最后一步并将这些值与我们的秒类型相关联。在西方音乐创作中,通常会设置拍每分钟节拍数(BPM)的一些概念。拍号通常关注如何划分和计算度量,而 BPM 给出精确的时间度量。但是,知道一分钟内有多少节拍是不够的,因为还必须知道整个音符中有多少节拍。

由于我们不想处理拍号,因为它们与我们创作音乐的方式无关(毕竟我们不是在一张纸上作曲),我们只想支持 BPM 的概念以及整个音符中有多少节拍。我们可以将这些信息编译成它自己的类型。

清单 7.19.有关速度的信息类型

type BPM = Double #1 data TempoInfo = TempoInfo #2 { beatsPerMinute :: BPM, beatsPerWholeNote :: Double }

使用TempoInfo类型,我们终于可以在现实世界(分钟度量)和我们的作品(整个音符)之间架起一座桥梁。为此,我们可以确定特定音符长度(是整个音符的一小部分)中有多少拍,一个节拍占用多少秒,最后确定特定音符长度填充了多少秒。其代码如示例 7.20 所示。

示例 7.20.将音符长度转换为时间的功能

timePerBeat :: BPM -> Seconds timePerBeat bpm = 60.0 / bpm #1 timePerNotelength :: TempoInfo -> Notelength -> Seconds timePerNotelength (TempoInfo beatsPerMinute beatsPerWholeNote) noteLength = let beatsForNoteLength = beatsPerWholeNote * toDouble noteLength #2 in beatsForNoteLength * timePerBeat beatsPerMinute #3 where toDouble :: Ratio Natural -> Double toDouble r = #4 (fromInteger . toInteger $ numerator r) / (fromInteger . toInteger $ denominator r)

在这里,我们使用从 Data.Ratio 导入的分子和分母函数来访问 Ratio 值的分子和分母。要将自然转换为双精度,我们首先必须将其转换为整数,然后将其转换回双精度 .

现在,在我们的作曲框架中处理了音高和速度,我们可以担心通过开发自己的结构来为我们的作曲带来结构,以了解音符和停顿之间的关系。

7.3 将结构置于艺术中

我们现在可以在我们的程序中讨论作曲。我们显然不想在一张纸上写下笔记。我们希望直接在我们的计划中做到这一点。从本质上讲,我们编译的 Haskell 二进制文件变成了乐曲,里面装着一个合成器,该合成器也可以播放乐曲!我们可以把它想象成我们自己的数字音乐盒。

那么我们如何在我们的程序中表示作品呢?在音乐中,我们可以区分音符停顿。乐器要么演奏音符,要么在一定时间内保持安静。这些音符和停顿按顺序排列。

图 7.10.旋律是一系列音符

如图7.10所示,旋律是一系列音符,和弦是一组音符,所有音符同时演奏。但是,音符序列也可以是序列或组。组也可以排序。例如,可以按顺序演奏多个和弦,如图 7.11 所示。那么我们如何对此进行建模呢?

图 7.11.同时演奏音符的一系列和弦

很明显,我们的数据结构需要允许递归或嵌套。否则,例如,我们不能允许序列序列。此外,我们必须考虑我们想要实现的目标。稍后,我们不想写下这种数据类型,而是使用运算符来构造注释、停顿、序列和组。我们希望能够混合所有这些元素,例如,将它们全部放入一个应该同时播放的组中。因此,无论我们建模什么,都应该包含在单个数据类型中,而不是多个数据类型中。满足所有这些限制的数据类型如清单 7.21 所示。

清单 7.21.用于构建注释的数据类型

data NoteStructure a = Note Notelength a #1 | Pause Notelength #2 | Sequence [NoteStructure a] #3 | Group [NoteStructure a] #4 deriving (Show) #5

对于这种类型,我们需要找到一种方法来将用它组成的任何东西转换为我们可以从中创建声音的东西。我们已经有一个声音类型,这是我们的事件。现在是时候将 NoteStructure 引入多个事件了。为此,我们为性能定义了一个类型,这是我们描述多个事件的方式。

type Performance = [Event]

此类型必须包含所有事件(具有正确的开始时间),以便我们可以将它们插入振荡器类型,并在此处插入一些甜美的音乐。为此,我们需要递归解析NoteStructure,跟踪持续时间并将元素组合到一个列表中。由于我们需要递归地执行此操作,因此我们知道要实现的函数需要具有 Seconds 参数,这是解析的当前时间。此外,我们需要一个 TempoInfo 参数,以了解音符或暂停需要多长时间的帮助,因为事件类型只知道秒作为时间的度量。因此,我们不仅需要性能,还需要秒,因为我们递归地需要一些关于经过时间的信息。有了这个,序列和组的解析是它们各自列表中的元素的折叠。对于序列,元素开始由已过的时间确定,返回的秒值由上次生成的事件结束给出。在一个组中,所有元素同时开始,返回的秒值由生成的最长事件的末尾给出。根据这些信息,我们可以将示例 7.22 中所示的函数放在一起。

示例 7.22.将音符和暂停的结构转换为可播放事件的功能

structureToPerformance :: (Pitchable a) => TempoInfo -> Seconds -> NoteStructure a -> (Seconds, Performance) structureToPerformance tempoInfo start structure = case structure of (Note length pitch) -> let freq = toFrequency pitch #1 duration = timePerNotelength tempoInfo length #2 in (start duration, [Tone {freq, start, duration}]) #3 (Pause length) -> let duration = timePerNotelength tempoInfo length #2 in (start duration, [Silence {start, duration}]) #4 (Group structs) -> foldl' f (start, []) structs #5 where f (durAcc, perf) struct = let (dur, tones) = structureToPerformance tempoInfo start struct #6 in (max dur durAcc, perf tones) #7 (Sequence structs) -> foldl' f (start, []) structs #5 where f (durAcc, perf) struct = let (newdur, tones) = structureToPerformance tempoInfo durAcc struct #8 in (newdur, perf tones) #9

此函数不会将事件值按其开始时间给出的正确顺序放置,并且需要一个特殊的开始参数,该参数应为0,当NoteStructure首次转换时。此外,它还返回一个具有一定持续时间的元组,当我们只想玩这些事件时,这对我们来说并不重要。因此,我们可以编写一个包装函数,该函数将这个元组作为返回值删除,并正确对值进行排序。如示例 7.23 所示。

清单 7.23.包装器函数,用于将音符结构转换为具有正确事件顺序的表演

toPerformance :: (Pitchable a) => TempoInfo -> NoteStructure a -> Performance toPerformance tempoInfo = sortBy (\x y -> compare (start x) (start y)) #1 . snd #2 . structureToPerformance tempoInfo 0 #3

有了这个函数,我们在作为NoteStructure值给出的合成和可由振荡器播放的表演之间建立了一座桥梁。

锻炼

虽然我们的 toPerformance 函数运行良好,但有一种改进的可能性。停顿总是导致沉默。我们的 NoteStructure 类型可以创建一系列静音。当然,这些沉默可以合并成大沉默。编写一个函数 Performance → Performance 来做到这一点,并将其添加到 toPerformance 函数中。想想我们如何测试这个函数。你能为这个函数想出一个快速检查属性吗?

但是,我们还没有实现振荡器可以发挥整个性能。这将是我们实现组合之前的最后一块拼图。

7.3.1 表演者表演表演

为了继续玩性能常规,我们希望通过一个简单的类型类来抽象它。我们这样做是为了以后可以添加更多可能的噪音制造者。毕竟,振荡器并不是唯一可以产生声音的东西。类型类如清单 7.24 所示。

清单 7.24.用于将性能转换为信号的类型类

class Performer p where play :: p -> Performance -> Signal

播放函数稍后将应用于表演以构建我们的信号。这怎么做呢?我们在这里处理的一个问题是事件可以自然地重叠。我们正在创建的合成器是复调的,这意味着它可以同时演奏多个音符。这要求我们首先考虑将多个信号混合为一个信号。为此,我们想编写一个函数,该函数可以添加信号而不会削波。当我们的样本水平超过 1 或达到低于 -1 的值时,就会发生削波。在这种情况下,我们的极限函数将切断信号并导致失真。应避免这种情况。因此,当我们添加信号时,我们必须确保它们永远不会超过最大和最小样本值。我们可以通过将添加的信号除以添加的信号的数量来实现这一点。添加信号本身类似于 zipWith ( ) ,但我们不能停留在较短的信号上。我们还必须包括来自较长信号的所有信号。但是,当不存在zipWithN时,我们如何添加任意数量的信号呢?答案以另一种方式出现在我们面前。通过将每个信号一个接一个地折叠添加,我们可以将它们全部相加,然后通过添加的信号数将样本除以。如清单 7.25 所示。

清单 7.25.将多个信号混合成单个信号的功能

mix :: [Signal] -> Signal mix signals = (/ n) <$> foldl' addSignals [] signals #1 where n :: Double n = fromIntegral $ length signals #2 addSignals :: Signal -> Signal -> Signal addSignals xs [] = xs #3 addSignals [] ys = ys #3 addSignals (x : xs) (y : ys) = (x y) : addSignals xs ys #4

处理好信号混合后,我们需要找到一种方法来识别重叠的事件值,以便我们可以对它们进行分组,使用振荡器播放它们,然后将信号重新混合在一起。我们在这里创建的是一种生成复调声音的方法,这意味着多个声音同时播放。在大多数合成器中,可以复音播放的最大声音数量通常受到硬件限制的限制。我们可以在图 7.12 中看到这个概念是如何工作的。

图 7.12.生成和弦音频信号背后的基本概念

由于我们的合成器不是实时的,因此我们具有令人难以置信的优势,即我们对声音的生成需要多长时间没有限制。如果我们允许自己花很长时间来计算声音,我们的合成器是无限复音的!

让我们回到那些同时播放的事件。要确定两个事件值是否重叠,我们可以使用开始字段选择器和结束函数来检查任何事件的开始是否位于另一个事件的持续时间内。如果是这种情况,事件重叠。检查这一点的函数如示例 7.26 所示。

清单 7.26.检查事件是否重叠的功能

overlaps :: Event -> Event -> Bool overlaps e1 e2 = start e1 `between` (start e2, end e2) #1 || start e2 `between` (start e1, end e1) #2 where between x (a, b) = x >= a && x <= b #3

仅使用 between 帮助程序函数,我们设计此函数的名称,以允许它以中缀样式编写以保持代码可读性。

锻炼

与许多函数一样,我们的混合和重叠函数具有一些属性。特别是混音是一个关键功能,由于前面解释的削波问题。编写一些检查此函数正确性的快速检查属性。

完成这些函数后,我们可以为振荡器编写我们的 Performer 实例。

7.3.2 复调组

在将表演转换为信号时,我们不想陷入一些陷阱。首先,事件值列表是有序的。因此,当我们折叠列表时,折叠方向很重要。其次,我们需要根据事件与其他事件重叠对事件进行分组。这将创建不相交的事件组,这些事件组之间可能有非显式的暂停,这意味着可能有一个事件在另一个事件开始之前很久就结束了。我们还需要确保将信号混合在一起,这样就不会发生削波,但是我们的混音功能已经解决了这个问题。

首先,我们来谈谈分组事件。当我们从左到右折叠事件时,我们可以从遇到的第一个事件开始,并将其放入自己的组中。对于性能中的下一个事件,我们首先必须评估它是否与当前组中的任何事件不重叠。如果没有重叠,我们可以将其添加到组中。如果有重叠,我们需要以事件作为其成员启动一个新组。我们对所有剩余事件重复此操作。此算法的一个重要特性是它不会更改组中事件的顺序。为了使语法可读,我们希望使用列表推导和 or 函数。

ghci> :t or or :: Foldable t => t Bool -> Bool ghci> :t and and :: Foldable t => t Bool -> Bool

or and and 函数很容易解释:它们折叠布尔值的可折叠对象,并构建这些值的析取或连接。这可以与列表推导式相当巧妙地一起使用,以动态生成布尔值列表,然后使用 or 或 and .

ghci> xs = [Tone 0 0 1, Tone 0 2 1] ghci> x1 = Tone 0 2 1 ghci> x2 = Tone 0 1.5 0.1 ghci> or [ x1 `overlaps` e | e <- xs ] True ghci> or [ x2 `overlaps` e | e <- xs ] False

由于这些表达式的计算结果为单个布尔值,因此我们可以将它们用作函数中的守卫!这有助于我们对列表上相当复杂的属性进行快速区分大小写。

现在,我们仍然需要考虑如何玩这些事件组。由于它们之间可能有隐式的停顿,因此对我们来说跟踪时间很重要。在递归函数中,我们可以简单地使用类似于我们在 structureToPerformance 函数中实现它的参数来做到这一点。然后我们需要扫描(从左到右)列表并使用我们的振荡器播放事件。如果事件组的顺序错误,我们可能会需要回到过去,播放一种我们可能已经充满沉默的音调。在这种情况下,我们无能为力,只能 错误失败 .无论如何,如果我们的其余实现是正确的,这种情况就永远不会发生。

在分组并从事件中生成信号之后,我们需要做的最后一件事就是将所有信号混合在一起。幸运的是,这是混合的简单用法。此函数的完整代码如示例 7.27 所示。

清单 7.27.使用振荡器播放性能的类型类实例

instance Performer Oscillator where play (Osc oscf) perf = mix $ fmap (playEvents 0) eventGroups #1 where eventGroups :: [[Event]] eventGroups = foldr insertGroup [] perf #2 where insertGroup x [] = [[x]] #3 insertGroup x (es : ess) | or [x `overlaps` e | e <- es] = es : insertGroup x ess #4 | otherwise = (x : es) : ess #5 playEvents :: Seconds -> [Event] -> Signal playEvents _ [] = [] #6 playEvents curTime (event : xs) | curTime < ts = concat [ silence (ts - curTime), #7 oscf event, playEvents te xs #8 ] | curTime == ts = oscf event playEvents te xs #9 | otherwise = error "Event occurs in the past!" #10 where ts = start event te = end event

请注意我们如何在 playEvents 函数中创建短路。如果组中没有剩余的事件,我们返回一个空信号,因为 mix 将负责达到正确的长度。

有了这个函数,终于可以播放我们的NoteStructure值,并播放我们的第一篇作品。

ghci> melody1 = Sequence [Note whole ("C4" :: Chromatic), Pause half, Note whole "F4"] ghci> melody2 = Sequence [Note whole ("F4" :: Chromatic), Note half "G4", Note whole "A4"] ghci> melody3 = Sequence [Note whole ("F3" :: Chromatic), Note half "C3", Note whole "C4"] ghci> group = Group [melody1, melody2, melody3] ghci> perf = toPerformance (TempoInfo 120 4) group ghci> perf [Tone {freq = 261.6255653005986, start = 0.0, duration = 2.0},Tone {freq = 349.2282314330039, start = 0.0, duration = 2.0},Tone {freq = 174.61411571650194, start = 0.0, duration = 2.0},Silence {start = 2.0, duration = 1.0},Tone {freq = 391.99543598174927, start = 2.0, duration = 1.0},Tone {freq = 130.8127826502993, start = 2.0, duration = 1.0},Tone {freq = 349.2282314330039, start = 3.0, duration = 2.0},Tone {freq = 440.0, start = 3.0, duration = 2.0},Tone {freq = 261.6255653005986, start = 3.0, duration = 2.0}] ghci> signal = play piano perf ghci> writeWav "performance.wav" signal Writing 460000 samples to performance.wav

在这里,我们看到一个小的动机,使用三个旋律,然后组合成一个组。音符同时播放,产生的信号不会削波。

美妙!但正如我们所看到的,即使是写下这么小的作文也是一种真正的痛苦。我们不想写下Haskell数据类型。我们想要的是更容易编写和理解的同一事物的表示,理想情况下,它呈现为远离底层数据类型的抽象。

7.4 作文语言

我们现在准备最终讨论如何在我们的程序中创作音乐。显然,我们对在无休止的复杂性的巨大语法树中手动写下构造函数不感兴趣。我们想要构建的是一种领域特定语言(DSL)。

Haskell允许使用自己的规则定义您自己的运算符,当涉及到它们与您指定的其余数据的关系时。此外,它允许非常容易地定义运算符的优先级,从而为我们想要表示的任何内容提供干净的语法。这使得Haskell成为一个很好的平台,用于开发针对特定用例的领域特定语言。

注意

对语义完全迂腐:当一种领域特定语言直接嵌入到编程语言中时,就像我们在本章中所做的那样,它更愿意被称为嵌入式领域特定语言或简称EDSL。但是,我们希望使事情更短一些,并省略无关紧要的区别。

我们将使用它来定义语法,使我们能够根据作曲家(DSL 的用户)永远不需要查看或了解的 NoteStructure 类型轻松创作音乐。我们首先要做的是写下笔记。我们目前的方法有点冗长:注意整个“C4”。构造函数有点冗长。我们可以创建一个中缀运算符来替换它。

(.|) :: (Pitchable a) => a -> Notelength -> NoteStructure a (.|) = flip Note

我们选择 .|作为操作员,因为它有点像音符。默认情况下,我们的定义定义了一个中缀运算符。第一个参数位于运算符前面,第二个参数位于运算符后面。替换构造函数很重要,因为 DSL 用户不必与基础 Notestructure 类型进行交互。请注意我们如何使用此运算符人为地限制多态性。我们明确只允许在我们的 NoteStructure 中使用具有 Pitchable 实例的类型,即使类型本身没有这种区别。我们这样做,以便用法变得更加清晰。

这个新运算符使笔记写得很简洁。然而,长度(整体)和音高值(“C4”)仍然非常冗长。为了获得较短的长度标识符,我们可以为 Notelength 常量创建较短的名称。

wn :: Notelength wn = whole hn :: Notelength hn = half ... sn :: Notelength sn = sixteenth

Notelength 值可以通过创建具有一个和两个字母名称的函数来类似地缩短,这些函数只需要八度。

a :: Natural -> Chromatic a = Chromatic A as :: Natural -> Chromatic as = Chromatic As ... gs :: Natural -> Chromatic gs = Chromatic Gs

这已经使写笔记变得非常容易。

ghci> c 4 .| wn Note (1 % 1) C4 ghci> f 8 .| triplet sn Note (1 % 24) F8 ghci> e 3 .| (100000 % 100001) Note (100000 % 100001) E3

我们可以通过为 Pause 构造函数创建一个一个字母的同义词来对暂停执行相同的操作。

p :: (Pitchable a) => Notelength -> NoteStructure a p = Pause

这是另一个需要处理的构造函数。接下来让我们研究序列和组。一般来说,我们的DSL的用户应该:

  1. 不关心列表
  2. 不关心如何组合序列和组
  3. 轻松使用重复

这意味着我们需要完全抽象排序和分组的概念。我们可以将它们设为自己的运算符,我们可以将其与注释和停顿组合在一起。对于序列,如果遇到两个序列,我们只需将它们组合成一个新序列。如果我们的运算符只有一个参数是一个序列,我们将另一个参数添加到其中。如果没有任何参数是序列,我们创建一个全新的序列。这个运算符如清单 7.28 所示。

清单 7.28.用于构造组合序列的中缀运算符

(<~>) :: NoteStructure a -> NoteStructure a -> NoteStructure a (<~>) (Sequence xs) (Sequence ys) = Sequence $ xs ys #1 (<~>) (Sequence xs) x = Sequence $ xs [x] #2 (<~>) x (Sequence xs) = Sequence $ x : xs #2 (<~>) a b = Sequence [a, b] #3

这让我们偷偷地避免,写下序列构造函数它所需的列表。

ghci> c 4 .| wn <~> e 4 .| wn <~> g 4 .| wn Sequence [Note (1 % 1) C4,Note (1 % 1) E4,Note (1 % 1) G4]

有趣的是,组的功能看起来非常非常相似(如果不是完全相同的话)。它的代码如示例 7.29 所示。

示例 7.29.用于构造组合组的中缀运算符

(<:>) :: NoteStructure a -> NoteStructure a -> NoteStructure a (<:>) (Group xs) (Group ys) = Group $ xs ys #1 (<:>) (Group xs) x = Group $ xs [x] #2 (<:>) x (Group xs) = Group $ x : xs #2 (<:>) a b = Group [a, b] #3

当然,这种相似性并非偶然。由于序列和组的结构是相同的,因此它们的运算符也相同是有道理的。唯一的区别在于构造函数的名称,因为这是决定以后如何解释数据的原因。

注意

本章中提出的DSL部分受到Haskore项目的启发,该项目具有类似的运算符。

现在,我们已经准备好了操作员,在我们开始之前还有最后一个问题要问。这个表达式的结果是什么: c 4 .|wn <~> e 4 .|WN <:> g 4 .|wn .这里我们有一个运算符优先级的问题。<~> 或 <:> 应该优先吗?这不是一个容易回答的问题,因为它取决于如何使用DSL,但是我们将<~>优先于<:>。那么我们如何做到这一点呢?

Haskell提供了声明优先级规则的语法。在第 5 章讨论 $ 运算符时,我们已经看到了它。它以关键字 infixr 、infixl 或 infix 开头,以确定运算符是右运算符还是左运算符,或者根本不关联。后面跟着 0 到 9 之间的优先级。声明通过提及我们正在谈论的运算符来完成。这称为固定性声明

对于我们的案例,我们将使所有运算符正确关联。(.|) 运算符必须优先于所有运算符,因为它不用于组合组合,而是构成原子构建块。然后 (<~>) 优先于 (<:>) 。通过以下固定性声明,我们可以强制执行这些规则。

infixr 4 .| infixr 3 <~> infixr 2 <:>

我们已经为我们的DSL处理了几乎所有所需的属性。最后是让重复更容易。如果我们想重复旋律或和弦,我们仍然需要复制声明。当然,我们可以发明新的运算符来实现重复!这些运算符可以将给定的 NoteStructure 复制指定的次数,并将它们包装在序列或组中。如清单 7.30 所示。

清单 7.30.重复合成的运算符

(<~|) :: NoteStructure a -> Natural -> NoteStructure a (<~|) (Sequence xs) n = Sequence $ concat [xs | _ <- [1 .. n]] (<~|) struct n = Sequence $ [struct | _ <- [1 .. n]] (|~>) :: Natural -> NoteStructure a -> NoteStructure a (|~>) = flip (<~|) (<:|) :: NoteStructure a -> Natural -> NoteStructure a (<:|) (Group xs) n = Group $ concat [xs | _ <- [1 .. n]] (<:|) struct n = Group $ [struct | _ <- [1 .. n]] (|:>) :: Natural -> NoteStructure a -> NoteStructure a (|:>) = flip (<:|)

为了使语法更加灵活,在左侧和右侧都有一个带有重复次数的运算符。由于重复应该“环绕”其他组合,因此这些运算符的优先级应低于所有其他运算符。

infixr 1 <~| infixr 1 |~> infixr 1 <:| infixr 1 |:>

让我们尝试一下我们的新语言。

ghci> c 4 .| wn <~> e 4 .| wn <:> g 4 .| wn <~| 2 Sequence [Group [Sequence [Note (1 % 1) C4,Note (1 % 1) E4],Note (1 % 1) G4],Group [Sequence [Note (1 % 1) C4,Note (1 % 1) E4],Note (1 % 1) G4]]

正如我们所看到的,语法变得更容易阅读,编写并且不需要我们的数据结构知识来构建。

锻炼

我们选择了 (<~>) 优先于 (<:>)。但是,我们本可以通过为两个运算符创建同义词并切换其优先级来避免此问题。然后,作曲家不需要使用括号,但可以切换到其他运算符!实现这些同义词。

我们有它。我们自己的数字音乐盒!

7.4.1 我们做了什么

让我们回顾一下我们在这里所做的工作。

首先,我们已经为一个领域特定的问题实现了类型和函数:音乐声音生成。我们在程序中将现实世界的属性建模为数据类型,并有效地对基本合成器技术进行了建模。在此过程中,我们创建了一个小框架来构建可以轻松扩展的合成仪器。

锻炼

我们的小合成器有些偏颇。它唯一的声音发生器是振荡器类型,它可以创建和弦和旋律,但在打击乐方面还有很多不足之处。牛铃在哪里?在电子音乐中,这通常是通过使用采样器来解决的,采样器是能够对其他音频(如鼓)进行采样并以音乐安排播放它们的机器。为我们的合成器框架实现这样的采样器类型。这将需要你变得狡猾。您需要考虑如何让此组件访问声音文件,如何触发播放这些文件以及这一切如何与我们的 DSL 配合使用。

其次,我们模拟了另一个领域特定的问题:音乐作曲。我们使用数据类型和各种函数将音乐作品的含义抽象为可以从我们的代码中更容易控制的东西。

第三,我们为上述作文主题创造了一种特定领域的语言。这种特定于领域的语言围绕我们的数据类型构建了一个抽象,即使不了解我们的内部实现也可以使用。它还可以扩展以允许更多样化的作曲技术,如随机构图(随机的花哨词)作曲。由于该语言嵌入在 Haskell 中,我们可以将我们在框架中实现的任何功能添加到其中。

剩下要做的就是测试我们的合成器并创作一些音乐。检查代码存储库以获取一些示例!

7.5 小结
查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved