Skip to main content

第 10 章 - 数字音乐盒(Digital music box)

本章内容包括:

  • 正确处理数值及其类型类
  • 使用无限列表
  • 用 Haskell 的数据类型建模特定领域的问题
  • 围绕底层实现构建抽象和高层结构

到目前为止,我们一直在处理很严肃的主题。古老的加密方案、最短路径和 CSV 文件处理都是正经活儿。在本章中,我们要稍微轻松一点,让创造力自由发挥:来做音乐!如果手边没有乐器,或者完全不知道如何作曲,也不用担心。我们将使用 Haskell 的能力,不仅把它当作自己的音乐合成器,还把它当作作曲工具,让我们能够轻松创造出下一首被听众铭记数百年的音乐杰作。

在程序中建模真实世界的数据,是程序员再平常不过的工作。本章会专门教你如何思考抽象细节,并把现实系统的一部分,也就是西方音乐理论系统,建模为类型和值。每当你想编写包含某种业务逻辑,或包含与软件规格、架构没有直接关系的概念的软件时,都会遇到这种建模。Haskell 允许我们进行高度抽象的建模,并完全省略对用例无关的细节。这正是本章想要达成的目标。

为了实现我们的音乐梦想,我们会在程序中建模声音,并编写能够产生声音的代码。在这个过程中,我们也会遇到 Haskell 数值和领域问题上的一些特殊之处,需要小心处理。有了创建声音的函数和类型之后,我们会围绕它们构建抽象,让我们能够在程序里方便地作曲,而不必操心底层实现。这会迫使我们思考什么才是正确的抽象,以及如何定义负责完成繁重工作的转换。

10.1 用数字建模声音(Modeling sound with numbers)

我们必须回答的最重要问题是:程序如何创建声音?声音到底是什么?在真实世界中,声音是空气中的振动,它轻轻刺激我们的耳膜,在大脑中产生刺激,最终形成听觉现象。对计算机来说,这件事稍微直接一些。对于单声道音频,声音就是一个信号。如果需要两个声道(左声道和右声道),声音就由两个信号组成。那么什么是信号?在模拟世界中,信号是一个随时间变化、分辨率可以任意细的值。可以把它看成以时间为参数的连续数学函数。在数字世界中,我们使用采样来近似模拟信号。样本只是一个单独的数值。一串样本就是一个采样信号,如图 10.1 所示。

模拟信号的采样

图 10.1 模拟信号的采样

从图中可以看到,重建模拟信号的准确性受限于采样率分辨率。采样率表示每秒采集多少个信号样本,分辨率表示用多少位来表示单个样本值。那么这两个属性的合理取值是什么?根据奈奎斯特-香农采样定理,如果我们想采样一个最高频率已知的信号,采样率至少必须是这个最高频率的两倍。对于音频信号,超过 22 kHz 的频率并不重要,因为人类听不到它们,而且大多数音频设备也无法重放这些频率。这就是常用的 44.1 kHz 采样率的来源,因为从理论上说,它足以完美采样声音信号。分辨率呢?它主要影响信号的信噪比,也就是想要的信号在背景噪声中能透出多少;背景噪声可能来自量化误差。音乐表示中常用 8 位到 32 位之间的样本。我们本章会使用 WAV 文件格式,而它通常使用 16 位表示。

10.1.1 数值类型类的“动物园”(The zoo of numeric type classes)

我们先关注如何表示刚才讨论的采样信号。前面提到过,单个样本只是一个数值。可以用 Double 表示它。因此,一个信号也不过是这些样本的列表,也就是 [Double]。这也带来一个决定:我们要支持什么采样率?虽然 92 kHz 这样更高的采样率可以帮助处理高频混叠,但这里我们选择标准 CD 品质音频,也就是 44.1 kHz。

我们还可以定义一些其他类型,让讨论主题时更方便。因为某些时候必须指定频率和时长,所以可以把频率定义为 Hz,把 Seconds 也定义为 Double。基于这些类型,可以创建第一批辅助函数,用来告诉我们:某个频率的一个周期需要多少样本,某个时长需要多少样本。后面这些函数会变得很重要。代码如下所示。

代码清单 10.1 处理信号的类型和辅助函数

type Sample = Double      -- #1

type Signal = [Sample] -- #1

type Hz = Double -- #1

type Seconds = Double -- #1

sampleRate :: Double -- #2
sampleRate = 44100

samplesPerPeriod :: Hz -> Int
samplesPerPeriod hz = round $ sampleRate / hz -- #3

samplesPerSecond :: Seconds -> Int
samplesPerSecond duration = round $ duration * sampleRate -- #4
  • #1 为样本、信号、赫兹和秒定义类型别名
  • #2 定义项目中使用的采样率常量
  • #3 计算表示给定频率的一个周期所需的样本数
  • #4 计算表示给定秒数所需的样本数

这里使用了 round 函数。它接收一个拥有 RealFrac 类型类实例的值,并返回一个拥有 Integral 类型类实例的值。这意味着我们可以把 FloatDouble 转换成 IntInteger。之所以这样做,是因为样本数量必须是整数,不存在半个样本。只要理解采样率就是一秒钟内的样本数,就能理解这两个函数。频率为 n 赫兹的周期正好是 1 / n。乘以采样率,就能得到容纳该频率一个周期所需的样本数量。

继续之前,我们先快速讨论一下 RealFracIntegral 这两个类型类。它们是什么?如何使用?事实证明,数值类型有一个庞大的类型类“动物园”,这两个类型类就是其中一部分。我们已经介绍过 Num,它定义了基础算术运算;不过奇怪的是,它并不提供除法操作,尽管我们已经见过两个执行除法的函数:

ghci> :t div
div :: Integral a => a -> a -> a
ghci> :t (/)
(/) :: Fractional a => a -> a -> a

这两个函数分别属于 IntegralFractional 类型类。Integral 类包含对整数进行除法和取余的函数,而 Fractional 类型类为浮点数定义了除法和倒数相关函数:

ghci> :i Integral
type Integral :: * -> Constraint
class (Real a, Enum a) => Integral a where
quot :: a -> a -> a
rem :: a -> a -> a
div :: a -> a -> a
mod :: a -> a -> a
quotRem :: a -> a -> (a, a)
divMod :: a -> a -> (a, a)
toInteger :: a -> Integer
{-# MINIMAL quotRem, toInteger #-}
instance Integral Word
instance Integral Integer
instance Integral Int
ghci> :i Fractional
type Fractional :: * -> Constraint
class Num a => Fractional a where
(/) :: a -> a -> a
recip :: a -> a
fromRational :: Rational -> a
{-# MINIMAL fromRational, (recip | (/)) #-}
instance Fractional Float
instance Fractional Double

Integral 类中一个重要函数是 toInteger,它让我们可以自由地把类型转换为 Integer 值。稍微有点奇妙的是,Integral 的约束中还出现了更多陌生类型类,也就是 RealEnum

ghci> :i Real
type Real :: * -> Constraint
class (Num a, Ord a) => Real a where
toRational :: a -> Rational
{-# MINIMAL toRational #-}
ghci> :i Enum
type Enum :: * -> Constraint
class Enum a where
succ :: a -> a
pred :: a -> a
toEnum :: Int -> a
fromEnum :: a -> Int
enumFrom :: a -> [a]
enumFromThen :: a -> a -> [a]
enumFromTo :: a -> a -> [a]
enumFromThenTo :: a -> a -> a -> [a]
{-# MINIMAL toEnum, fromEnum #-}

拥有 Real 实例的类型可以转换为 RationalRational 是一种表示整数比值的类型,本章后面会再看它。

注意 从数学上说,Real 类型类的名字并不准确,因为理论上无理数,例如 Prelude 中的 pi,也属于这个类,并且可以通过 toRational 转换成有理数。这是理论与实践发生分歧的一个地方。

Enum 类定义了类型上的枚举;当我们使用列表范围语法时,它就会被用到。第 15 章会更仔细地讨论这个类:

ghci> toRational (5 :: Double)
5 % 1
ghci> toRational (5.5 :: Double)
11 % 2
ghci> [1..10] :: [Int]
[1,2,3,4,5,6,7,8,9,10]
ghci> enumFromTo 1 10 :: [Int]
[1,2,3,4,5,6,7,8,9,10]

现在可以看一下 RealFrac。这是一个把分数转换成整数值的函数集合:

ghci> :i RealFrac
type RealFrac :: * -> Constraint
class (Real a, Fractional a) => RealFrac a where
properFraction :: Integral b => a -> (b, a)
truncate :: Integral b => a -> b
round :: Integral b => a -> b
ceiling :: Integral b => a -> b
floor :: Integral b => a -> b
{-# MINIMAL properFraction #-}

roundceilingfloor 是我们把浮点数转换为整数的方式。round 返回离参数最近的整数;ceiling 返回不小于参数的最小整数;floor 返回不大于参数的最大整数。Fractional 的另一个子类是 Floating,它包含若干三角函数和数学函数,例如 sinlogsqrt,用于计算正弦、对数和平方根。

虽然这个类型类动物园看起来很复杂,但可以安心的是,我们通常使用的数值类型,例如 IntIntegerFloatDouble,只要语义合理,都会拥有这些类型类的实例。这让我们可以使用 fromIntegralInt 转换为 Float,也可以使用 roundDouble 转换为 Integer

ghci> fromIntegral (1 :: Int) :: Float
1.0
ghci> round (3.1415 :: Double) :: Integer
3

不过,Floating 没有整数值实例。因此,当我们想计算一个整数的正弦时,必须显式执行转换,因为 Haskell 中不存在隐式类型强制转换或类型转换:

ghci> sin (fromIntegral (1 :: Int)) :: Double
0.8414709848078965

还有一个本书不会覆盖的类是 RealFloat。它可以用来访问浮点数的底层组成部分,但与我们的项目无关。

10.1.2 创建周期函数(Creating periodic functions)

现在数值类型类的细节已经说清楚了,我们可以开始关心如何创建声音。在音乐中,我们会关注音色,也就是声音的质感特征。不过在深入泛音这个大坑之前,可以先走一条捷径,看看其他合成器是如何工作的。信号使用不同种类的波形,就可以获得不同的声音特征。最标准的四种波形如图 10.2 所示。

四种标准波形

图 10.2 四种标准波形

这些波形展示的是信号中的单个周期。周期如何重复,由信号长度和我们尝试生成的频率决定。如果时间固定,频率变化会拉伸或压缩信号。samplesPerPeriodsamplesPerSecond 可用于计算某个频率的一个周期需要多少样本。把这些样本重复到填满整个时长,就能得到一个在指定时间内保持某个频率的音。改变这些频率,就能产生音乐!就我们的用途而言,样本值范围为 -11。后面想控制信号音量时,我们再处理调制问题。现在先实现这些波形。

在我们的模型中,波就是一个函数:它接收一个介于 01 之间的数值参数,并返回波形在该位置的样本值。然后可以用这个参数在波形上“扫描”。扫描得越快,也就是单个时间步之间的距离越大,周期就越短,频率就越高。由于图 10.2 中的波形只是数学函数,所以可以用代码建模。正弦波可以用 Prelude 中已有的 sin 函数生成。方波根据输入参数做分支判断:当输入小于等于 0.5 时返回 -1,否则返回 1。锯齿波和三角波需要多想一点,因为我们必须构造斜坡。锯齿波是输入 t 上的斜率 2 * t,再偏移 -1。三角波需要构造两段斜坡。前半段是长度正好减半的锯齿波,因此可计算为 4 * t - 1。后半段中,计算值的符号会切换,因为斜坡需要镜像。为了做到这一点,我们把偏移设置为 +3,从而创建从 1-1 的值。代码如下所示。

代码清单 10.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
  • #1 把波形定义为函数类型
  • #2 使用 Prelude 中的 pi 常量定义正弦波形
  • #3 定义占空比为 50% 的方波
  • #4 定义锯齿波
  • #5 定义三角波

所有这些函数只在输入介于 01 之间时有效。对于该范围之外的值,函数会变为常量。对我们的用途来说,函数在有效输入范围之外做什么并不重要。

现在可以使用计算样本数的函数,构造用于创建静音的函数,以及使用某个波形在特定时长内生成某个频率音调的函数。静音只是值恒为 0 的信号。为此,我们先计算需要生成多少样本,并从 0 枚举到这个样本数。然后,把这些值按单周期所需样本数取模,再除以该周期样本数,这样就能得到正确频率下的周期位置。这会创建一个输入参数列表,随后可以把它传给任意 Wave 函数。得到的信号就是我们的音调。代码如下所示。

代码清单 10.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]
  • #1 返回一个长度匹配、值恒为 0 的信号
  • #2 把波函数映射到重复值上,创建所需信号
  • #3 计算波形单个周期所需的样本数
  • #4 创建一个包含重复输入参数的列表,用于在指定时长和频率下生成音调

这里又必须使用 fromIntegral,因为我们要做浮点数除法,而不是整数除法。在这个函数中,我们手动确保 wave 函数在正确时长内收到重复值。这看起来有点复杂,因为我们其实也可以只计算一次波形,然后反复使用它。稍后的练习会给你机会实现这种方式。

练习:可调占空比振荡器

在这些波形中,我们已经实现了方波。方波比较特殊,它是一种所谓的脉冲波,且占空比为 50%,也就是说信号处于“低”状态和“高”状态的时间相等。脉冲波可以为任意占空比定义,而且用于音乐时,声音特征会随着占空比发生变化。请实现一个通用脉冲波形,并让它支持可调占空比。

最后,我们终于可以使用这些函数创建真实声音了!为此,会使用一个名为 HCodecs 的库。它可以读写 WAV 文件,而 WAV 是一种未压缩音频格式,大多数音频播放器都能播放。为了让这个库可用,需要把 HCodecsarray 加到 package.yml 文件的依赖部分。这个库提供了一个名为 exportFile 的函数,可用于把音频数据写入 WAV 文件。package.yml 的依赖部分应如下所示:

dependencies:
- base >= 4.7 && < 5
- HCodecs >= 0.5.2
- array >= 0.5.4

为了完成写出操作,必须构造一个 Audio 值。它是一个记录,包含采样率、样本数据和声道数等信息。为了构造这个值,我们首先必须把 Signal 转换成库中称为 SampleData 的东西,它内部使用数组存储音频数据。Haskell 中确实有数组,但本书不会覆盖它们,所以这里不深入讨论。对我们来说,重要的是需要把 array 加到依赖中才能使用它们。然后,可以用 listArray 函数从列表构造数组。这个函数接收一个元组作为参数,用来指定数组边界,同时接收要转换的列表数据。这里需要确保元组指定的范围是从 0 到列表长度减 1。为了把我们的 Sample 类型转换成 HCodecs 能理解的样本,可以使用 fromSample 函数。完整代码如下所示。

代码清单 10.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
  • #1 导入用于非装箱数组的 listArray 函数
  • #2 导入 HCodecs 库提供的函数和类型
  • #3 导入 Int16 类型
  • #4 把 Signal 转换为 16 位样本数组

Signal 转换为 SampleData 时,需要确保值位于 -11 之间。可以通过一个限制值的函数来保证这一点。不过我们永远不想真正触碰这些边界值,所以应该用一个小因子对受限信号做衰减。借助 minmax,可以把值夹到阈值范围内。实现这个目标的函数如下所示。

代码清单 10.5 限制信号值的函数

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

这里可以看到,在 map 函数内部组合了三个操作。被映射的值先乘以阈值,然后再通过 maxmin 进行夹取。

现在可以把这些函数组合起来,创建一个把 Signal 写入 WAV 文件的 IO 动作。它会使用 HCodecs 库中的类型和函数。代码如下所示。

代码清单 10.6 把音频数据写入 WAV 文件的函数

import Util.Types
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
  • #1 导入 HCodecs 库提供的函数
  • #2 把样本绝对值夹到阈值以下,并转换为 HCodecs 能理解的样本数据
  • #3 创建可导出为 WAV 文件的音频数据
  • #4 把音频数据导出到指定路径的 WAV 文件

现在终于可以用这些函数创建 WAV 文件了:

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

这会创建一个名为 4waves.wav 的文件,其中包含 20 秒音频,分成 4 段不同波形,每段都以 220 Hz 播放 5 秒。由于信号只是一个简单列表,所以可以使用 (++)concatconcatMap 这样的函数拼接信号。

可以用 Audacity 这样的免费音频编辑器检查我们创建出的声音文件。图 10.3 展示了它的样子。

导出的音频文件波形

图 10.3 导出的音频文件波形

我们也可以用音频播放器或编辑器收听这个文件。不过要小心:目前这段音频非常响,而且听起来并不太悦耳。

10.2 使用无限列表(Using infinite lists)

接下来,我们想给音调增加一些轮廓。你在把玩刚创建的音调生成器时可能已经听到,有些声音停止时会出现“咔嗒”声。这是因为到达目标时长时,我们的周期函数可能被突然截断。图 10.4 展示了这种情况。我们需要某种方式塑造音调轮廓,尤其是信号末尾,来消除这个咔嗒声。

波形突然变化导致音频中出现“咔嗒”声的示例

图 10.4 波形突然变化导致音频中出现“咔嗒”声的示例

10.2.1 ADSR(Attack, decay, sustain, and release)

在大多数合成器中,这通过一个塑造信号振幅的包络来实现,通常会让开头和结尾逐渐变化。对于我们的合成器,我们想实现非常常见的 attack、decay、sustain 和 release,也就是 ADSR 包络。这个包络让信号在一段时长内上升(attack),然后花一段时间衰减(decay)到固定值(sustain),最后慢慢淡出(release)。图 10.5 展示了这样的包络,以及包络如何影响信号。

ADSR 包络及其对信号的影响

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

这带来一个新问题:如何表示 ADSR 包络?由于包络需要影响信号的振幅,可以把它理解为一组我们想乘到信号上的因子,如图 10.5 所示。所以,解决方案仍然是一个简单的 [Double]

为了表示包络参数,可以使用记录。attack、decay 和 release 都是时长。这里它们的曲线都是线性的,虽然在其他合成器中不一定如此。不过 sustain 是一个保持的电平。这个电平保持多久,由被调制信号的时长以及其他参数长度决定。这样的包络参数代码如下所示。

代码清单 10.7 表示 ADSR 包络参数的类型

data ADSR = ADSR     -- #1
{ attack :: Seconds, -- #2
decay :: Seconds, -- #2
sustain :: Double, -- #2
release :: Seconds -- #2
}
deriving (Show) -- #3
  • #1 为 ADSR 包络定义新数据类型
  • #2 定义 ADSR 包络各参数的类型
  • #3 派生 Show 类型类实例

现在可以思考如何把这些参数应用到信号上。因为必须为样本生成介于 01 之间的因子列表,所以可以使用熟悉的列表操作。

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

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

这已经可以作为 attack 曲线。对于 decay,我们想做类似的事情,但要创建从 1 到指定值的线性下降。

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

把这个列表反转后,就得到了 decay。不过还要确保它可以接在 attack 后面。由于 attack 已经包含最大值 1,我们不希望它在 decay 中重复出现。可以通过让基础列表从 0 开始,并数到目标长度减 1 来修正:

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

这里构造出了一个衰减到持续值 0.5 的 decay。说到 sustain,它只是一个固定值,所以可以用 replicate 生成该值的列表。不过接着必须计算需要多少个值,而这个数量应该由仍需追加的 release 样本数决定。我们可以复用构建 attack 曲线的逻辑来构建 release 曲线,再用这种方式修改 sustain。但这些曲线该如何组合呢?

10.2.2 构建与操作无限列表(Building and working with infinite lists)

回想一下已经在列表上用过的函数,可以想到 zipWith。它用给定函数组合两个列表,并在较短列表结束时停止。另外还有 zipWith3,它处理三个列表而不是两个列表。我们可以把 attack、decay 和 sustain 视为一个列表,把 release 曲线视为另一个列表。它们还必须与信号组合。现在,只需要构建 release 曲线。虽然可以围绕信号长度和每一部分需要多少值做大量计算,但我们可以借助无限列表简化这个过程。

Haskell 的惰性有一个有趣特性:允许我们创建无限但不会被无限求值的定义。列表定义可以是无限递归的,而不会导致问题,因为函数不一定需要求值所有这些无限多个值:

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

只要使用不会求值整个列表的函数,例如 taketakeWhile(!!)head 等,就可以愉快地处理这些列表,甚至可以映射、过滤或压缩它们。这个想法也适用于其他可以无限生成的数据类型,例如图。

对于列表,我们可以用熟悉的范围表达式轻松创建无限列表:只要省略最大值即可。这样,生成所有自然数或所有奇数几乎变得很简单:

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

警告 无限数据结构很酷,但也很危险。必须非常小心,确保不会调用试图完整求值它们的函数。一旦无法确定数据是有限的,我们为数据处理制定的规则就会失效。对无限列表调用 length 会永远运行下去,从而导致程序无限挂起。

对我们的用途来说,需要重复某些固定值,例如 release 曲线开头的 1 和 sustain 电平。这可以用名字非常贴切的 repeat 函数实现,它返回一个只包含单个值的无限列表:

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]

回到包络的用例,我们可以使用一个列表组合 attack 和 decay 曲线,然后无限重复 sustain 电平。第二个列表可以像 attack 曲线一样构建 release 曲线,并重复 1,再从该曲线中取出与信号一样多的元素并反转。把这些曲线与原始信号相乘,就能得到想要的结果。

练习:循环列表

就像 repeat 会创建只包含单个值的无限列表一样,cycle 函数接收一个列表作为参数,并无限循环这个列表中的值。在 tone 函数中,我们使用 mod 手动计算了循环波形。现在请使用 cycletake 重新实现 tone 函数:先计算一次波形,然后循环使用它。

这个包络的实现如下所示。函数会计算曲线,并将其应用到作为参数给出的信号上。zipWith3 的使用保证整个信号都会被影响,因为 sustain 是无限长的,而 release 与信号一样长。因此,这个函数的正确性来自其构造方式。

代码清单 10.8 对信号应用 ADSR 包络的函数

import Util.Types

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)
(map (/ releaseSamples) [0.0 .. releaseSamples] ++ repeat 1.0) -- #10
  • #1 把组合后的 attack、decay、release 曲线与 sustain 电平和信号相乘
  • #2 把 attack、decay 曲线与 sustain 电平拼接起来
  • #3 计算每条曲线所需的样本数
  • #4 把 attack 曲线计算为线性斜坡
  • #5 反转 decay 曲线斜坡
  • #6 把 decay 曲线计算为从 sustain 电平到 1 的线性斜坡
  • #7 生成由 sustain 电平组成的无限列表
  • #8 反转 release 曲线
  • #9 从 release 曲线中取出与信号样本数相同的值
  • #10 把 release 曲线计算为从 0 到 1 的线性斜坡,后面接无限多个 1

现在可以把这个包络应用到音调上,并第一次听到轮廓:

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

在音频编辑器中打开生成的文件,可以看到它的轮廓。线性 attack 曲线用 100 毫秒到达完整音量。随后,信号在 200 毫秒内线性衰减到 sustain 电平,也就是半音量(0.5)。release 曲线再在 100 毫秒内把音量降到 0。图 10.6 展示了这个结果。

应用 ADSR 包络后的信号

图 10.6 应用 ADSR 包络后的信号

有了这个包络,就可以更剧烈地塑造音调。使用较短的 attack、decay 和 release,并把 sustain 电平保持得很低,就能创建“拨弦”般的声音。把 attack 和 decay 保持得更长,则可以创建逐渐膨胀的声音。音调生成和轮廓塑造都处理完后,就可以构建我们的发声器了。在合成器领域,它们被称为振荡器。

10.3 控制合成(Controlling synthesis)

合成器必须完成一项工作:接收控制信号,并产生合适的声音。控制信号可能来自键盘、电子信号或数字串口。我们也想控制自己的噪声。更具体地说,我们想控制生成音调的音高、时长和出现时间,以便创作音乐。

10.3.1 部分字段选择器(Partial field selectors)

为此,我们需要为音调生成器编码事件。一个事件要么是在特定时刻、以指定频率、持续一定时长播放的音调,要么是同样拥有明确开始时间和持续时间的静音。我们使用记录语法把这些事件编码成一个类型。然后可以定义一些辅助函数,用于区分音调和静音,并计算事件结束时间。代码如下所示。

代码清单 10.9 表示演奏事件的类型和辅助函数

module Composition.Performance where

import Util.Types

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
  • #1 定义一个音调构造器,包含指定频率、开始时间和持续时长
  • #2 定义一个静音构造器,包含指定开始时间和持续时长
  • #3 为 Event 类型派生 Show 实例
  • #4 定义辅助函数,用于判断 Event 是否表示音调
  • #5 定义辅助函数,用于判断 Event 是否表示静音
  • #6 定义辅助函数,用于计算事件结束时间

这个类型中值得注意的是字段的使用。第 7 章讲过,记录语法会自动为记录中指定的字段创建函数,这些函数可用于取回对应字段的值。当一个类型拥有多个使用记录语法的构造器时,也会如此:

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

如果一个字段被多个构造器共享,生成出来的函数,也就是字段选择器,足够聪明,可以匹配正确字段。因此,在 end 函数定义中使用 startduration 是没问题的:

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

不过,freq 比较特殊,因为它并不出现在所有构造器中。这让它成为一个部分字段选择器

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

本质上,部分字段选择器就是部分函数,也就是说,它们对某些值没有定义。这让它们使用起来很危险,应该格外小心,尽量避免。

注意 使用 -Wpartial-field 编译标志时,GHC 可以在出现部分字段选择器时自动发出警告。在编译过程中启用这类警告总是好事。有时候你可能并不想给代码添加部分字段,却在重构另一个记录字段时意外引入了它;这个警告就能帮你抓住这种问题。

我们不想暴露部分函数。那么如何调和这个问题?一种处理方式是使用 NoFieldSelectors 语言扩展,它会禁用模块中记录字段函数的自动创建。这样,我们就可以自己定义访问字段的函数:

{-# LANGUAGE NoFieldSelectors #-}      -- #1

module Composition.Performance
( Event (..), -- #2
start, -- #3
end, -- #3
)
where

...

data Event
= Tone {freq :: Hz, start :: Seconds, duration :: Seconds}
| Silence {start :: Seconds, duration :: Seconds}
deriving (Show)

start :: Event -> Seconds -- #4
start (Tone _ s _) = s
start (Silence s _) = s

duration :: Event -> Seconds -- #4
duration (Tone _ _ d) = d
duration (Silence _ d) = d
  • #1 启用 NoFieldSelectors 语言扩展
  • #2 导出 Event 类型及其构造器
  • #3 导出在顶层定义的自定义字段选择函数
  • #4 定义访问 Event 记录字段的函数

现在,模块会导出适用于两个构造器的 startduration。不过,危险的部分字段选择器被隐藏起来了。

10.3.2 函数作为类型(A function as a type)

现在已经有了事件定义,可以开始关心如何创建基于事件产生信号的组件。由于它们和音乐合成器中的振荡器相关,在代码中我们把它们称为 Oscillator。大多数情况下,这些振荡器会预先选择一个波形,并直接连接到某个用于塑造轮廓的包络。振荡器类型是一个简单的 newtype,它把函数 Event -> Signal 包装进新类型。随后,可以构造一个函数,把 Wave 函数和 ADSR 参数组合起来,创建一个能够从事件生成音调的振荡器。代码如下所示。

代码清单 10.10 定义振荡器的函数

module Sound.Synth (
...
) where

import Composition.Performance

...

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
  • #1 为振荡器定义一个类型,并提供底层函数的字段选择器
  • #2 把创建出的函数包装到 Osc 构造器中
  • #3 为指定时长创建静音
  • #4 创建一个包含指定频率、指定时长音调的信号,并用 ADSR 包络塑造信号轮廓

可以看到,我们忽略了事件的 start 参数。这是因为我们不希望在振荡器内部关心信号如何组合。最终,我们的合成器可以拥有多个振荡器,它们会播放各自的音调,有时在完全不同的时刻,有时互相重叠。为了正确处理这一点,后面还需要做一些整理工作。但目前可以先构建不同振荡器,它们各自的特征由底层波形和 ADSR 参数决定。下面的代码清单展示了几个发声器候选。你可以尝试不同波形和 ADSR 参数,打造自己的音乐机器。

代码清单 10.11 定义振荡器的函数

module Sound.Synth (
...
) where

import Prelude hiding (sin)
import qualified Prelude

...

piano :: Oscillator
piano = osc saw $ ADSR 0.01 0.6 0.3 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 0.01 -- #4

bass :: Oscillator
bass = osc tri $ ADSR 0.001 0.2 0.9 0.1 -- #5
  • #1 定义一个带有瞬态响应和较长 sustain 的锐利振荡器
  • #2 定义一个音量几乎恒定的柔和振荡器
  • #3 定义一个 attack 和 decay 都很长、逐渐膨胀的锐利振荡器
  • #4 定义一个没有 sustain 的拨弦式振荡器
  • #5 定义一个平静而响亮、可用于较低音调的振荡器

借助 Oscillator 类型的字段选择器,可以自己试试这些振荡器:

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

生成出的信号会让我们看到:切换波形或声音轮廓时,声音会发生多么剧烈的变化。这就完成了合成器中声音生成部分的工作。

练习:颤音效果

我们已经为声音生成创建了一组基础功能。真实世界中的合成器在声音塑形方面更灵活,会实现滤波器以及其他类型的包络。请通过添加调制能力扩展振荡器:另一个低频信号(频率为 0 到 10 Hz)可以随时间影响振荡器的某个特征。当我们影响信号振幅时,这称为颤音效果。请实现这个效果,让合成器更有深度。

现在发声器已经准备就绪,我们可以开始关心作曲:如何建模音符、如何对音符分组,以及如何播放这些作品。

10.4 音符模型(Note models)

当我们想创建音乐作品时,需要一种方式,把音高和时长与某种全局时间度量联系起来。本章不打算变成一堂无聊的乐理课,所以我们会跳过大部分细节。不过,对音乐理论建模的实现本身对我们来说很有意思。

10.4.1 音高类型类(A type class for pitches)

先处理音高。对于 Oscillator 类型,我们已经有了一种量化音高的方法,也就是 Hz,信号的频率。不过,贝多芬和莫扎特作曲时用的不是频率,而是音乐音符。它们与 Oscillator 能创建的东西有什么关系?这就是接下来要搞清楚的。

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

代码清单 10.12 可转换为频率的类型类

{-# LANGUAGE TypeSynonymInstances #-}
module Composition.Pitch where

class Pitchable a where -- #1
toFrequency :: a -> Hz

instance Pitchable Hz where
toFrequency = id -- #2
  • #1 定义一个可转换为频率的类型类
  • #2 为 Hz 类型创建 toFrequency 函数实现

这个类只包含一个函数,用于把某个类型转换为频率。第一个实例已经给出,也就是 Hz 自身。当然,频率转换为频率只需要……什么都不做。因此,在这个实例中,toFrequency 的实现就是恒等函数。

在键盘上,也就是钢琴键盘而不是连接电脑的键盘,音乐音符按半音排列,如图 10.7 所示。手指从一个键移动到下一个键,会让播放的音改变一个半音。为这些半音创建一个类型是合理的,但这个类型应该长什么样呢?

音乐键盘以及相对标准音 A 的半音距离

图 10.7 音乐键盘以及相对标准音 A 的半音距离

今天演奏的大多数音乐都围绕同一种调音。这种调音把标准音 A 定义为 440 Hz。当我们把半音定义为与标准音 A 的距离(以半音计)时,就可以使用图 10.8 中这个顺手的小公式来计算音符频率。

在十二平均律中,根据相对标准音 A 的半音距离 s 计算频率 f 的公式

图 10.8 在十二平均律中,根据相对标准音 A 的半音距离 s 计算频率 f 的公式

这就引出了合成器中半音的定义。半音只是一个 Integer,表示到标准音 A 的某个距离。创建 Pitchable Semitone 实例所需的计算如图 10.8 所示。代码如下。

代码清单 10.13 半音类型

{-# LANGUAGE TypeSynonymInstances #-}     -- #1
module Composition.Pitch where

...

type Semitone = Integer -- #2

instance Pitchable Semitone where
toFrequency semitone =
440 * (2.0 ** (fromInteger semitone / 12)) -- #3
  • #1 启用 TypeSynonymInstances 语言扩展,允许为类型同义词创建类实例
  • #2 为半音创建类型同义词
  • #3 计算给定半音距离相对标准音 A 的频率

现在可以试试这些计算。预期行为是,从某个半音向上或向下移动一个八度时,频率应该加倍或减半:

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 个半音之后,频率应该变成两倍或一半。这听起来很像一个形式化属性!请为 Semitone 的频率计算编写 QuickCheck 测试。构造这个测试时会出现什么问题?如何修复它?

这里有一个小秘密:音乐音符不过是半音的漂亮名字。标准音 A 也叫 A4,其中 A 是八度内半音的名字,4 指定八度。八度和半音数量是有限的,因为人类听觉大致局限在 20 Hz 到 20 kHz 的范围内。低于 20 Hz 的东西听起来不太像音高,更像节奏;而超过 20 kHz 的大多数频率,人类听不到,只有部分动物能感知。

回到音符。键盘上一个八度内的半音会被拆分成音名。由一个半音间隔分开的音符,也称为半音阶。这些音名如图 10.9 所示。

带音名的音乐键盘

图 10.9 带音名的音乐键盘

我们该如何表示这些音符?理论上,可以把所有可能值枚举成一个巨大的求和类型,例如:

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

不过,这不仅写起来痛苦,模式匹配也同样痛苦。因此,我们希望把音符处于哪个八度,以及它相对该八度基准的半音偏移(由音名给出)拆开。于是,把半音阶音名和实际半音阶音符拆成两个数据类型就很合理。对于与半音阶音符关联的数字,不允许负数是有意义的,因此我们希望使用 Numeric.Natural 中的 Natural 类型。这个类型编码自然数,也就是非负整数。新数据类型代码如下。

代码清单 10.14 半音阶音符类型

module Composition.Pitch where

...

import Numeric.Natural (Natural) -- #1

...

data ChromaticName = A | As | B | C | Cs | D | Ds | E | F | Fs | G | Gs -- #2
deriving (Show, Eq) -- #3

data Chromatic
= Chromatic ChromaticName Natural -- #4
deriving (Show, Eq) -- #5
  • #1 导入 Natural 类型定义
  • #2 把半音阶音名定义为求和类型
  • #3 为 ChromaticName 类型派生若干类型类实例
  • #4 定义半音阶音符类型
  • #5 为 Chromatic 类型派生 ShowEq 类型类实例

遗憾的是,直接写这个类型的值有点麻烦。可以为每个可能的音名定义辅助函数,只要求传入该值所在的八度:

a :: Natural -> Chromatic
a = Chromatic A

as :: Natural -> Chromatic
as = Chromatic As

...

gs :: Natural -> Chromatic
gs = Chromatic Gs

最后一步是把这些音符转换为频率。既然它们只是半音的漂亮名字,而我们已经知道如何从半音计算频率,那么最终任务就是把半音阶音符转换为这些半音。我们知道 A4 必须是 0,而相对 A 偏移一个音名就等于偏移一个半音。因此,可以用音符在八度内的偏移,加上八度编号减 4 后再乘以 12 的值(因为一个八度包含 12 个半音),来计算半音数。偏移可以直接枚举。也可以利用这一点计算频率,如下所示。

代码清单 10.15 从半音阶音符转换为半音和频率

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
  • #1 通过把音符在八度内的偏移加到该八度的半音编号上,计算半音值
  • #2 定义各个半音阶音名的偏移
  • #3 先把半音阶音符转换为 Semitone 值,再使用该类型的 toFrequency 实现计算频率

同样,可以检查频率和八度的属性是否也适用于这个新类型:

ghci> toFrequency (a 4)
440.0
ghci> toFrequency (a 5)
880.0
ghci> toFrequency (a 3)
220.0
ghci> toFrequency (c 2)
65.40639132514966
ghci> toFrequency (c 3) / 2
65.40639132514966

有了这些类型作为构件,我们就能创建第一段小旋律:

ghci> melody = map toFrequency [c 4, e 4, g 4, d 5, c 5  :: Chromatic]
ghci> signal = concatMap (\f -> playEvent piano $ Tone f 0 0.7) melody
ghci> writeWav "melody.wav" signal

这当然不错,但听起来有些单调。所有音符都播放相同时间。我们可以改变每个音符的时长,但还没有一种优雅的书写方式。

练习:不同音高的属性

请为这个新类型创建新的 QuickCheck 属性,同时实现 Arbitrary Chromatic 实例,并检查到 Semitone 以及 Hz 的转换是否正确。

在作曲中,我们需要一种描述音符长度的方式。接下来就处理这个问题。

10.4.2 Ratio 类型(The Ratio type)

音乐音符按比值分类。这些比值指定音符占用多少时间,也就是占一个小节的几分之几。全音符占一整个小节,二分音符占半个小节,四分音符占四分之一个小节,依此类推。另外,这些比值总是正数,因为我们没有“负时间”的概念。那么在代码中该如何表示它们?

虽然可以使用 DoubleFloat 表示这些比值,但使用 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

可以看到,Ratio 的 kind 是 * -> *,所以它由另一个类型参数化。(%) 运算符确保只能使用整数类型。因此可以构造 Ratio IntRatio Integer,但不能构造 Ratio Double

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

我们在查看 toRational 函数时已经见过一种 Ratio 类型:Rational。它不过是 Ratio Integer 的别名:

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

就我们的用途而言,我们想在类型层面禁止负值,所以使用 Natural 是合理的。幸运的是,Natural 也有 Integral 类型类实例。借助它,我们已经可以构造西方音乐理论中许多常见的音符长度。

重要 当我们想表示非负数值时,Natural 看起来像是一个不错选择,但并不总是如此。当 Natural 数上的某个计算进入负值时(这是可能的,因为存在 Num Natural 实例,所以可以对它们做减法),会抛出异常。这可能导致程序在意料之外的时刻崩溃。

音符长度类型以及一些常量如下所示。

代码清单 10.16 音符长度类型和常见常量

module Composition.Notelength where

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
  • #1 从 Data.Ratio 导入 Ratio 类型和 (%) 运算符
  • #2 从 Numeric.Natural 导入 Natural 类型
  • #3 把音符长度定义为自然数之比
  • #4 定义若干常见音符长度

不过,西方乐谱中还存在音符长度修饰符。我们感兴趣的是附点音符连音。附点音符可以理解为对原音符的延长。如果一个音符带附点,它的时长会增加原长度的一半。这也可以做多次,所以一个音符可以带两个附点,甚至三个附点。连音很有意思,因为它让我们能创建普通比值之外的不规则节奏。它会用不同方式细分拍子,来表示写成连音的音符。连音也由某个常数参数化;“三连音”使用 3,“五连音”使用 5,这些都是常见值。音符时长会乘以 2 除以连音值,所以三连音是二分之三的倒数,也就是乘以二分之三;五连音则乘以二分之五。

人类对能够理解和演奏的音符长度复杂度有一定限制,但我们的合成器不受这种渺小的生物限制。因此,我们允许任意附点和任意连音。可以把这些修饰符实现为简单函数,如下所示。

代码清单 10.17 音符长度修饰函数

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

10.4.3 不同类型的指数运算(Different kinds of exponentiation)

代码清单 10.17 中值得注意的一点是 (^) 运算符的使用。之前我们使用 (**) 计算频率,但现在使用 (^),尽管两者都在做指数运算。更令人困惑的是,还有第三个指数运算符,看起来有点像一个开心表情:(^^)。这是怎么回事?为什么有这么多?这些运算符各自在实现上都有一点小怪癖。先看看它们的类型:

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 :: Double) ** (5 :: Double)
2.4883199999999994
ghci> (1.2 :: Double) ^ (5 :: Int)
2.48832
ghci> (1.2 :: Double) ^^ (5 :: Int)
2.48832
ghci> (1.2 :: Double) ^ (-5 :: Int)
*** Exception: Negative exponent
ghci> (1.2 :: Double) ^^ (-5 :: Int)
0.4018775720164609
ghci> (1.2 :: Double) ** (-5 :: Double)
0.40187757201646096

可以看到,这很快就会变得混乱,而且正确的运算符选择并不总是显而易见。一般来说,对整数使用 (^),对有理值使用 (^^),对浮点数使用 (**)。不过,一旦出现负指数,仍然存在例外情况。

现在我们已经很好地理解了音符长度,还需要走最后一步:把这些值与 Seconds 类型联系起来。在西方音乐作曲中,通常会设置拍号,以及某种每分钟拍数(BPM)的概念。拍号通常关心如何划分和计数小节,而 BPM 给出精确时间度量。不过,知道一分钟有多少拍还不够,因为还必须知道一个全音符包含多少拍。

我们不想处理拍号,因为它与我们如何作曲无关(毕竟我们不是在五线谱纸上作曲),所以只想支持 BPM 以及一个全音符中有多少拍的概念。可以把这些信息汇总到自己的类型中,如下所示。

代码清单 10.18 节奏信息类型

type BPM = Double       -- #1

data TempoInfo = TempoInfo -- #2
{ beatsPerMinute :: BPM,
beatsPerWholeNote :: Double
}
  • #1 把 BPM 类型定义为 Double
  • #2 定义一个类型,指定一分钟有多少拍,以及一个全音符有多少拍

有了 TempoInfo 类型,终于可以在真实世界(分钟这一度量)和我们的作品(全音符)之间架桥。为此,可以确定特定音符长度中包含多少拍(它是全音符的一个分数)、一拍占多少秒,以及特定音符长度占多少秒。代码如下所示。

代码清单 10.19 把音符长度转换为时间的函数

module Composition.Notelength where

import Util.Types (Seconds)
import Data.Ratio (Ratio, (%), denominator, numerator)

...

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)
  • #1 计算指定 BPM 下每拍的秒数
  • #2 计算指定音符长度包含多少拍
  • #3 计算指定音符长度包含多少秒
  • #4 通过把分子和分母转换为 Double 并手动相除,把 Ratio Natural 转换为 Double

这里使用了从 Data.Ratio 导入的 numeratordenominator 函数,用于访问 Ratio 值的分子和分母。为了把 Natural 转换为 Double,需要先把它转换为 Integer,再转换回 Double

现在,作曲框架中的音高和速度都已经处理好,我们可以开始为作品增加形式:开发自己的结构,用来表示音符和停顿如何彼此关联。下一章会继续探索这一点。

总结

  • IntegralFractional 类型类分别为整数值和浮点值定义除法函数。
  • Enum 类型类定义如何为类型构建值的枚举,并被用于列表范围语法。
  • RealFrac 类型类中的 roundceilingfloor 函数用于把浮点数转换为整数;fromIntegral 可用于把整数转换为浮点数。
  • 列表可以惰性创建为包含无限多个元素,只有访问元素时才会真正创建它们。
  • 必须谨慎处理无限列表,因为不应完整求值它们。
  • 在拥有多个构造器的记录类型中复用同名字段时,如果某个字段并不存在于所有构造器中,就可能产生部分字段选择器。
  • 使用 NoFieldSelectors 语言扩展,可以禁止自动创建字段选择器。
  • Ratio 类型定义数字之间的比值。
  • 计算指数时,我们对整数使用 (^),对有理值使用 (^^),对浮点数使用 (**)