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

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

Haskell入门教程7 数字音乐盒 休闲娱乐

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 #1type Signal = [Sample] #1type Hz = Double #1type Seconds = Double #1sampleRate :: Double #2sampleRate = 92000samplesPerPeriod :: Hz -> IntsamplesPerPeriod hz = round $ sampleRate / hz #3samplesPerSecond :: Seconds -> IntsamplesPerSecond 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 #1sin :: Wavesin t = Prelude.sin $ 2 pi t #2sqw :: Wavesqw t | t <= 0.5 = -1 #3 | otherwise = 1 #3saw :: Wavesaw t | t < 0 = -1 #4 | t > 1 = 1 #4 | otherwise = (2 t) - 1 #4tri :: Wavetri 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 -> Signalsilence t = replicate (samplesPerSecond t) 0 #1tone :: Wave -> Hz -> Seconds -> Signaltone 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) #1import Data.Audio #2import Data.Int (Int16) #3signalToSampleData :: Signal -> SampleData Int16signalToSampleData signal = listArray (0, n) $ map fromSample signal #4 where n = length signal - 1

将旗子暗记转换为 SampleData 时,我们须要确保我们的值在 -1 到 1 之间,我们可以通过限定值的函数来确保这一点。
但是,我们永久不想达到这些值,因此我们该当通过一个小成分来抑制有限的旗子暗记。
通过利用最小值和最大值,我们可以将值限定为阈值。
实现此目的的函数如清单 7.5 所示。

清单 7.5.箝位旗子暗记值的功能

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

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

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

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

import qualified Codec.Wav #1import Data.Audio #1writeWav :: 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.5ghci> 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 : onesghci> 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 -> Signaladsr (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.1ghci> 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) #3isTone :: Event -> Bool #4isTone Tone {} = TrueisTone _ = FalseisSilence :: Event -> Bool #5isSilence Silence {} = TrueisSilence _ = Falseend :: Event -> Seconds #6end e = start e + duration e

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

ghci> :t startstart :: Event -> Secondsghci> :t durationduration :: Event -> Secondsghci> :t freqfreq :: Event -> Hz

如果一个字段由多个布局函数共享,则天生的函数(称为字段选择器)足够智能,仍旧可以匹配精确的字段。
这便是为什么开始和持续韶光的利用对付结束函数的定义是可以的。

ghci> start $ Tone 0 1 21.0ghci> start $ Silence 1 21.0

但是,频率是分外的,由于它不会涌如今所有布局函数中 这使它成为部分字段选择器

ghci> freq $ Tone 440 1 2440.0ghci> 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} #1osc :: Wave -> ADSR -> Oscillatorosc 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 :: Oscillatorpiano = osc saw $ ADSR 0.01 0.1 0.2 0.2 #1ocarina :: Oscillatorocarina = osc sin $ ADSR 0.01 0.3 0.7 0.01 #2violin :: Oscillatorviolin = osc saw $ ADSR 2 2 0.1 0.2 #3pluck :: Oscillatorpluck = osc sqw $ ADSR 0.01 0.05 0 0.01 #4bass :: Oscillatorbass = 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)) oscsghci> writeWav "oscillators.wav" signal

由此产生的旗子暗记向我们展示了在切换波形或声音轮廓时发生的声音剧烈变革。
我们在合成器的声音天生部分的事情到此结束。

磨炼

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

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

7.2 音符模型

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

7.2.1 投球思路

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

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

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

class Pitchable a where #1 toFrequency :: a -> Hzinstance 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 #-} #1type Semitone = Integer #2instance Pitchable Semitone where toFrequency semitone = 440 (2.0 (fromInteger semitone / 12)) #3

现在我们可以考试测验打算了。
估量一旦我们从特定半音上升或降落一个八度,频率该当增加一倍或一半。

ghci> toFrequency (0 :: Semitone)440.0ghci> toFrequency (12 :: Semitone)880.0ghci> toFrequency (-12 :: Semitone)220.0ghci> toFrequency (5 :: Semitone)587.3295358348151ghci> toFrequency (5+12 :: Semitone) / 2587.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) #1data ChromaticName = A | As | B | C | Cs | D | Ds | E | F | Fs | G | Gs #2 deriving (Read, Show, Eq) #3data Chromatic = Chromatic ChromaticName Natural #4 deriving (Show, Eq) #5

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

7.2.2 阅读我们展示的内容

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

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

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

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

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

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

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

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

ghci> :t AA :: ChromaticNameghci> :t AA :: ChromaticNameghci> show A"A"ghci> read "A" :: ChromaticNameA

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

清单 7.15.半音符的类型

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

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

ghci> "A4" :: ChromaticA4

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

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

chromaticToSemitone :: Chromatic -> SemitonechromaticToSemitone (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 #2instance Pitchable Chromatic where toFrequency = toFrequency . chromaticToSemitone #3

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

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

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

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

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

磨炼

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

我们在作文中须要的是一种评论辩论音符长度的办法,我们接下来将要办理。

7.2.3 自然音符长度

音符按比例分类。
比率指定音符占用的柱线韶光。
全体音符霸占全体小节,半个音符霸占一半,四分之一音符霸占四分之一,依此类推。
此外,比率始终是正的,由于我们没有“负韶光”的观点。
那么,我们如何在代码中表示它们呢?

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

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

ghci> import Data.Ratioghci> :k RatioRatio :: -> ghci> 1 % 2 :: Ratio Int1 % 2ghci> 3 % 2 + 1 % 2 :: Ratio Int2 % 1ghci> 4 % 5 - 2 % 10 :: Ratio Int3 % 5

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

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

Ratio Integer在Haskell中也被称为Rational。

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

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

主要

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

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

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

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

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

虽然人类对音符长度的繁芜性有一些限定,但他们仍旧可以阐明和实行我们的合成器不受这种微薄的生物限定的阻碍。
因此,我们将许可任意点和元组。
我们可以将这些润色符实现为示例 7.18 中所示的大略函数。

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

dots :: Natural -> Notelength -> Notelengthdots n x = x + x (1 % 2 ^ n)dotted :: Notelength -> Notelengthdotted = dots 1doubleDotted :: Notelength -> NotelengthdoubleDotted = dots 2tripleDotted :: Notelength -> NotelengthtripleDotted = dots 3tuplet :: Natural -> Notelength -> Notelengthtuplet n x = x (2 % n)triplet :: Notelength -> Notelengthtriplet = tuplet 3quintuplet :: Notelength -> Notelengthquintuplet = tuplet 5

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

ghci> nl = 1 % 2 :: Notelengthghci> dotted nl3 % 4ghci> triplet nl1 % 3

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

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

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

ghci> 1.2 5.1 :: Double2.534103535654163ghci> 1.2 5 :: Double2.4883199999999994ghci> 1.2 ^ 5 :: Double2.48832ghci> 1.2 ^^ 5 :: Double2.48832ghci> 1.2 ^ (-5) :: Double Exception: Negative exponentghci> 1.2 ^^ (-5) :: Double0.4018775720164609ghci> 1.2 (-5) :: Double0.40187757201646096

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

7.2.4 我的节奏

现在,我们已经很好地节制了音符长度,我们必须采纳末了一步并将这些值与我们的秒类型干系联。
在西方音乐创作中,常日会设置拍每分钟节拍数(BPM)的一些观点。
拍号常日关注如何划分和打算度量,而 BPM 给出精确的韶光度量。
但是,知道一分钟内有多少节拍是不足的,由于还必须知道全体音符中有多少节拍。

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

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

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

利用TempoInfo类型,我们终于可以在现实天下(分钟度量)和我们的作品(全体音符)之间架起一座桥梁。
为此,我们可以确定特定音符长度(是全体音符的一小部分)中有多少拍,一个节拍占用多少秒,末了确定特定音符长度添补了多少秒。
其代码如示例 7.20 所示。

示例 7.20.将音符长度转换为韶光的功能

timePerBeat :: BPM -> SecondstimePerBeat bpm = 60.0 / bpm #1timePerNotelength :: TempoInfo -> Notelength -> SecondstimePerNotelength (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 -> PerformancetoPerformance 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] -> Signalmix 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 -> Booloverlaps 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 oror :: Foldable t => t Bool -> Boolghci> :t andand :: 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 1ghci> x2 = Tone 0 1.5 0.1ghci> or [ x1 `overlaps` e | e <- xs ]Trueghci> 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) groupghci> 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 perfghci> writeWav "performance.wav" signalWriting 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 :: Notelengthwn = wholehn :: Notelengthhn = half...sn :: Notelengthsn = sixteenth

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

a :: Natural -> Chromatica = Chromatic Aas :: Natural -> Chromaticas = Chromatic As...gs :: Natural -> Chromaticgs = Chromatic Gs

这已经使写条记变得非常随意马虎。

ghci> c 4 .| wnNote (1 % 1) C4ghci> f 8 .| triplet snNote (1 % 24) F8ghci> e 3 .| (100000 % 100001)Note (100000 % 100001) E3

我们可以通过为 Pause 布局函数创建一个一个字母的同义词来对停息实行相同的操作。

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

这是另一个须要处理的布局函数。
接下来让我们研究序列和组。
一样平常来说,我们的DSL的用户该当:

不关心列表不关心如何组合序列和组轻松利用重复

这意味着我们须要完备抽象排序和分组的观点。
我们可以将它们设为自己的运算符,我们可以将其与注释和停顿组合在一起。
对付序列,如果碰着两个序列,我们只需将它们组合成一个新序列。
如果我们的运算符只有一个参数是一个序列,我们将另一个参数添加到个中。
如果没有任何参数是序列,我们创建一个全新的序列。
这个运算符如清单 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 .| wnSequence [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 <~| 2Sequence [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 小结无限数据构造可用于简化算法并非所有布局函数共享的记录字段是部分函数,因此很危险Ratio 类型可用于对有理数进行建模,同时仍旧可以访问分子和分母自然类型可用于逼迫类型或函数的参数必须是非负数幂具有多个运算符,这些运算符的精度和性能各不相同读取类型类是显示类型类的对偶我们可以通过利用中缀、中缀和中缀固定性声明来定义我们定义的运算符的关联性和优先级