介绍 Extempore 实时编程语言和环境,并详细探讨了使用 Extempore 演奏音乐的方法和技巧。

Extempore 是一套实时编程语言和运行环境,它提供了一个机体编程 (Cyberphysical Programming)[1] 环境,以支持对多媒体和实时系统的实时编程(Live Coding)。所谓机体编程,就是允许编程者可以在任意时刻自由地修改程序并即时影响系统的运作,达到“即写即执行”。官方的介绍如下:

Extempore is a programming language and runtime environment designed to support ‘cyberphysical programming’. Cyberphysical programming supports the notion of a human programmer operating as an active agent in a real-time distributed network of environmentally aware systems. The programmer interacts with the distributed real-time system procedurally by modifying code on-the-fly.

Extempore 的前身是 Impromptu ,由 MOSO 公司的创始人 Andrew Sorensen 所设计和开发,并托管在 Github 上。在进一步介绍它之前,先看看 Andrew Sorensen 在 OSCON 2014 上的演示吧:

是不是很酷 ^_^ ?Andrew Sorensen 以计算机仿真音乐作为例子演示了机体编程的用途:通过任意时刻的人机交互,来实现对目标实时系统的控制。这个实时系统和实际的环境配置有关(Environment-aware)——既可以是一个实时虚拟交响乐系统,也可以是一个实时图形系统实时物理模拟系统等等。从构造上看,这类系统有个共同点,就是通常是由分布式的网络环境构成。Extempore 还具有非常强烈的时序和并发概念,可以很好地应用在时序非常重要的场合(比如音频和视频)。

安装配置 Extempore

本节介绍 Extempore 的安装配置。我使用的环境是 OSX + Emacs,可以使用 Homebrew 安装 extempore:

安装 Extempore

1
2
$ brew tap benswift/extempore
$ brew install extempore

安装完成后,可以试着执行以下 Extempore 进程(服务器):

1
2
$ cd /usr/local/Cellar/extempore/X.XX/ # X.XX 需要替换成实际安装的版本号
$ ./extempore

如果你看到下面的界面,说明安装成功:

可以通过执行 ./extempore --help 来了解更多的启动选项。

到这里读者应该就可以大致明白 Extempore 的运行方式了:首先启动一个或多个服务器,这些服务器将侦听各自的端口。之后,我们可以通过编辑器或者其他界面与这些服务器实时互动,比如将代码发送给某一个服务器让其立即执行。这也是大部分支持 REPL (read-eval-print-loop)的编程语言的特点,比如 Common Lisp/Scheme/Python/Ruby/Matlab 。

后面会介绍如何在 Emacs 下启用 Extempore 服务器,所以我们可以先 C-c C-c 结束掉这个进程。

安装 Emacs 插件

extempore 的安装路径里头已经包含了相应的 Emacs 插件,只需要将以下几行添加到你的 Emacs 配置文件中:

1
2
3
(autoload 'extempore-mode "/path/to/extempore/extras/extempore.el" "" t)
(add-to-list 'auto-mode-alist '("\\.xtm$" . extempore-mode))
(setq user-extempore-directory "/path/to/extempore/")

注意将第一行的路径改为插件实际所在位置。

完成后使用 eval-region 执行这几行指令,或者重启 Emacs ,使配置生效。自此插件就安装完成。当我们打开 .xtm 文件时,major mode就会变成 Extempore-mode 。但此时 Extempore 的进程服务器还没有启动,编辑器也还没有和服务器建立连接。因此我们可以执行以下几步:

  1. M-x Extempore-run 或者 M-x switch-to-Extempore 启动一个 Extempore 服务器,记住服务器的端口号(如果已经有 Extempore 服务器在运行,并且我们希望连接它,则可以跳过这一步)。
  2. M-x Extempore-connect 让 Emacs 连接一个 Extempore 服务器。记住,每个 .xtm 文件的 buffer 都需要完成至少一次连接。

Extempore 设计哲学

在介绍 Extempore 的语法前,先了解下它的设计哲学是很有必要的。Extempore 的设计是为了同时实现两个目标:动态灵活性,及尽可能接近 C 语言的速度。为了同时达到这两个目标,Extempore 首先保证了对 Scheme 语言的支持,然后在保留 Scheme 的语法风格的基础上,加入了类 C 语言的强类型的支持,设计出了 xtlang 语言 。一个 Extempore 进程既可以解释弱类型的 Scheme ,也可以编译强类型定义的 xtlang 。Scheme 的对象(列表、闭包、continuation 等)可以和 xtlang 的类型http://benswift.me/2012-08-13-understanding-pointers-in-xtlang.html共存,且借助于一些“辅助函数”,数据可以在两种语言间自由传输。在实现上,Extempore 使用 LLVM 作为编译器后端。在处理 xtlang 时,先将代码编译成 LLVM 指令,再进一步交由 LLVM 编译和链接。

可以被 Extempore 处理的 Scheme 和 xtlang 语言的示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(define scheme-closure
(lambda (a b)
(let ((result (* a b)))
(print "result = " result)
result)))
(scheme-closure 4 5) ;; prints "result = 20", returns 20
(scheme-closure 2.4 2) ;; prints "result = 4.8", returns 4.8
(bind-func xtlang_closure
(lambda (c:double d:i64)
(let ((result (* c (i64tod d))))
(printf "result = %f\n" result)
result)))
(xtlang_closure 4.5 2) ;; prints "result = 9.000000", returns 9.0

本文并不打算介绍过多 Scheme 的语法,建议读者自己阅读 Lisp 相关的语法教程入门。例如 [TSPL](Scheme 教程)。

xtlang 的基本类型

xtlang基本类型一览

整型

  • i1 (boolean)
  • i8 (char)
  • i32
  • i64 (default)

浮点型

  • float (32 bit)
  • double (64 bit, default)

指针

例如:

  • double* :a pointer to a double
  • i64**: a pointer to a pointer to a 64-bit integer

xtlang 的指针与 C 语言的指针非常类似(你可以通过 %pprintf 它们)。对 Extempore 的指针更深入的介绍可以参考这篇文章

空间分配

要分配一块空间并让一个指针指向它,Extempore 提供了三种 alloc 函数:salloc、halloc 和 zalloc 。它们用用来分配一块内存空间,并返回一个指针类型,但它们的区别在于空间的分配方式,这将决定不同的占用时间。salloc 是在栈分配空间的(短期),zalloc 则在当前的区域分配空间,而 halloc 则在堆分配空间(长期)。alloc 是 zalloc 的别名。更多关于三种函数的探讨可以参考这篇文章。zalloc 的一个示例:

1
2
3
4
5
6
(bind-func ptr_test
(lambda ()
(let ((a:double* (zalloc)))
(printf "address = %p\n" a))))
(ptr_test) ;; prints "address = 0x1163bc030"

在这个例子中,eptr_test 实现将一个指针绑定到一个 double 型变量 a ,并将该指针指向的地址打印出来。

解引用

与在 C 语言不同,* 并非解引用操作符。xtlang 使用一个函数 pref 来实现指针的解引用。pref 需要两个参数:指针名和一个(整型)偏移量。如果 a 是一个指向内存中的一块 10 个 double 值的指针,那么 (pref a 2) 就是第三个 double 值(相当于 C 语言中的 a[2])。

指针赋值

要为指针指向的空间赋值,可以使用 pset! ,它接受三个参数:指针名、偏移量、要赋的值。示例:

1
2
3
4
5
6
7
8
(bind-func ptr_test2
(lambda ()
(let ((a:double* (zalloc))) ; allocate some memory for a double, bind
; the pointer to the symbol a
(pset! a 0 2.4) ; set the value at index 0 (of a) to 2.4
(pref a 0)))) ; read the value at index 0 of a
(ptr_test2) ;; returns 2.400000

如果要一次性为一个指针指向的一组空间赋值,可以使用 pfill! 。示例:

1
2
3
4
5
6
7
(bind-func ptr_test3
(lambda ()
(let ((a:double* (zalloc 4)))
(pfill! a 1.2 3.4 4.2 1.1) ; fill the pointer a with values
(pref a 2)))) ; read the value at index 2 of a
(ptr_test3) ;; returns 4.200000

上例的 pfill! 相当于连续使用 pset! 四次,要要求值和空间精确对应。一个更有用的方法是使用循环:

1
2
3
4
5
6
7
8
9
(bind-func ptr_test4
(lambda ()
(let ((a:double* (zalloc 10))
(i:i64 0))
(dotimes (i 0)
(pset! a i (i64tod i)))
(pref a 6))))
(ptr_test4) ;; returns 5.000000

还有一个有用的使用指针的函数:pref-ptr。例如, (pref a 3) 返回的是 a 指针指向的空间的第 4 个元素的值,而 (pref-ptr a 3) 返回指向该值的指针。这也意味着 (pref (pref-ptr a n))(pref (pref-ptr a 0) n) 等效(n 可以为任意整数)。

复合类型

  • 元组 Tuples 。使用 <...> 声明。例如 <double,i32>* 是一个指向包含 2 个元素的元组的指针。第一个元素是一个 double 而第二个元素是一个 i32 值。
  • 数组 Arrays 。使用 |...| 声明。例如 |4,double|* 是一个指向包含 4 个 double 的数组的指针。
  • 向量 Vector。使用 /.../ 声明。例如 /4,float/* 是一个指向 4 个 float 的向量的指针。

这些类型的解引用等操作与指针的对应操作非常类似,名字也大同小异。例如,元组的解引用、赋值等操作以 t 开头,分别为 treftset!tfill!tref-ptr 。Array 以 a 开头, Vector 以 v 开头。

闭包类型

闭包类型使用 [...] 声明,括号里头的第一个元素表示返回值类型,其他元素则是函数参数类型。例如 [i64,double,double]* 是一个指向一个有两个 double 参数,返回值类型为 i64 的闭包的指针。

在 xtlang 中,创建一个闭包可以使用 lambda(匿名函数) 或 bind-func(有名函数)。例如:

1
2
3
4
5
(bind-func xt_add
(lambda (a:i64 b:i64)
(+ a b)))
(xt_add 3 6) ;; returns 9

Extempore 实时音乐编程

Extempore 最为人所知的应用莫过于用来编写音乐。Extempore 既可以编写较为底层的 DSP,也可以编写较为高级的(基于音名的)音频。

简单 dsp 函数

Extempore 提供了一个特殊的函数 dsp ,该函数返回的值将直接输出给音频驱动器,从而实现声音的输出。例如:

1
2
3
4
5
6
7
8
9
10
(bind-val STWOPI SAMPLE 6.2831853071795864769252867665590057683943387987502116419498)
(bind-func dsp
(lambda (in:SAMPLE time:i64 chan:i64 data:SAMPLE*)
(sin (/ (* STWOPI 440.0 (convert time SAMPLE))
44100.0))))
; to let Extempore know that this function is the one
; it should call to get the output audio samples
(dsp:set! dsp)

dsp 函数接受以下几个参数:

  • in:输入音频采样,例如麦克风。
  • time:一个 i64 值,表示时间。
  • chan:另一个 i64 值,表示声道序号(0 表示左,1 表示右,以此类推)。Extempore 支持处理任意声道。
  • data:一个指向 SAMPLE 类型(默认为 float)的数据的指针,也可以用来传递其他数据给 dsp 函数。

默认情况下,SAMPLE 的类型是 float ,定义如下:

1
(bind-alias SAMPLE float)

它也可以设为 double ,具体见这篇文章

编写振荡器

一个简单的振荡器 2 2振荡器(英文:oscillator)是用来产生重复电子讯号(通常是正弦波或方波)的电子元件。

1
2
3
4
5
6
(bind-func osc_c ; osc_c is the same as last time
(lambda (phase)
(lambda (amp freq)
(let ((inc:SAMPLE (* 3.141592 (* 2.0 (/ freq 44100.0)))))
(set! phase (+ phase inc))
(* amp (sin phase))))))

osc_c 函数接受一个参数相位 phase,该函数并不返回一个基本类型的值,而是返回一个指向另一个闭包的指针,该闭包就是一个“振荡器”,参数为幅度值 amp 和频率 freq 。这种写法在 xtlang 中非常常见,为了方便标识,我们习惯性将一个返回另一个闭包的闭包的名字加上 _c 后缀[2]

我们可以使用 osc_c 函数创建任意数量的振荡器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
; remember that the dsp closure is called for every sample
; also, for convenience, let's make a type signature for the
; DSP closure
(bind-alias DSP [SAMPLE,SAMPLE,i64,i64,SAMPLE*]*)
(bind-func dsp:DSP ; note the use of the type signature 'DSP'
(let ((osc1 (osc_c 0.0))
(osc2 (osc_c 0.0))
(osc3 (osc_c 0.0)))
(lambda (in time channel data)
(cond ((= channel 1)
(+ (osc1 0.5 220.0)
(osc2 0.5 350.0)))
((= channel 0)
(osc3 0.5 210.0))
(else 0.0)))))

振荡器闭包中的 phase 变量用来维护 osc1 或 osc2 的调用状态。每当闭包被调用,phase 就会递增(见上面 osc_c 的定义)。每个 osc 的 phase 变量是彼此独立的,从而每个 osc_c 创建的振荡器是彼此独立的。

借助元组,上面的代码可以写成更简洁的形式:

1
2
3
4
5
6
7
8
9
10
11
12
(bind-alias osc_t [SAMPLE,SAMPLE,SAMPLE]*)
(bind-func dsp:DSP
(let ((osc_tuple:<osc_t,osc_t,osc_t>* (alloc)))
(tfill! osc_tuple (osc_c 0.0) (osc_c 0.0) (osc_c 0.0))
(lambda (in time channel data)
(cond ((= channel 1)
(+ ((tref osc_tuple 0) 0.5 220.0)
((tref osc_tuple 1) 0.5 350.0)))
((= channel 0)
((tref osc_tuple 2) 0.5 210.0))
(else 0.0)))))

下面是一个更复杂的例子,使用数组存放每个振荡器的参数信息,生成 30 个白噪声振荡器。前 15 个振荡器放在左声道输出,后 15 个振荡器放在右声道输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(bind-func dsp:DSP
(let ((osc_array:|30,[SAMPLE,SAMPLE,SAMPLE]*|* (alloc))
(amp_array:|30,SAMPLE|* (alloc))
(freq_array:|30,SAMPLE|* (alloc))
(i 0))
; initialise the arrays
(dotimes (i 30)
(aset! osc_array i (osc_c 0.0))
(aset! amp_array i (+ 0.2 (* 0.2 (random))))
(aset! freq_array i (+ 110.0 (* 1000.0 (random)))))
; this is the dsp closure
(lambda (in time chan data)
(cond ((= chan 0) ; left channel
(let ((suml 0.0))
(dotimes (i 15) ; sum over the first 15 oscs
(set! suml (+ suml ((aref osc_array i)
(aref amp_array i)
(aref freq_array i)))))
(/ suml 15.0))) ; normalise over all oscs
((= chan 1) ; left channel
(let ((sumr 0.0))
(dotimes (i 15 30) ; sum over the last 15 oscs
(set! sumr (+ sumr ((aref osc_array i)
(aref amp_array i)
(aref freq_array i)))))
(/ sumr 15.0)))
(else 0.0))))) ; any remaining channels

读取音频文件

Extempore 的 libsndfile 库绑定提供了文件读写的支持。

1
2
3
4
5
6
7
8
(sys:load "libs/external/sndfile.xtm")
(bind-func dsp:DSP 1000000000 ;; allocate memory to store the audio file
(let ((audiofile (audiofile_c "/Users/ben/Desktop/xtm-assets/peg.wav" 0 0)))
(lambda (in time chan dat)
;; get the output sample
(audiofile))))
(dsp:set! dsp)

audiofile_c 接收三个参数:

  • 音频文件的名字
  • 起始的文件读取偏移位置(0 表示文件开头)
  • 要读取的采样数(0 表示整个文件)

使用采样器

在 Extempore 中,采样器是一个存放了音频并可供触发和回放的乐器,它可以用来模拟乐器或其他事物的声音(例如,一个钢琴采样器存放了真实录制的钢琴声,利用这个采样器,MIDI 键盘可以触发这些声音来模拟真实钢琴的演奏)。因为采样器非常有用,所以 Extempore 专门提供了一个内置的采样器 libs/external/instruments_ext.xtm 。

我们可以将采样器想象成一堆“槽”,每个槽装着一个音频文件。

每个槽用一个独一无二的序号区分。播放采样器通常就是指定某个序号的槽的音频以某个响度/频率和长度来播放。上图左侧的数字是每个音名对应的MIDI音名数字(middle C=60)。例如,如果你需要触发 middle C 的采样,只需使用 play-note 消息并带上音高参数 60

Extempore 的采样器并不要求装满——允许出现空槽。

在这种情况下,当采样器发现要采样的槽里为空时,会找到最近的非空槽,取出该音频,并线性调整它的音高,以播放出期望音高的声音。

创建一个采样器

使用 define-sampler 创建一个 Extempore 采样器。

下面将演示创建一个鼓的采样器。音频文件可以在这里下载到。

1
2
3
4
5
6
7
8
9
10
11
12
13
(sys:load "libs/external/instruments_ext.xtm")
;; define a sampler (called drums) using the default sampler note kernel and effects
(define-sampler drums sampler_note_hermite_c sampler_fx)
;; add the sampler to the dsp output callback
(bind-func dsp:DSP
(lambda (in time chan dat)
(cond ((< chan 2)
(drums in time chan dat))
(else 0.0))))
(dsp:set! dsp)

接下来把每个音频放进每个槽里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(define drum-path "/Users/ben/Music/sample-libs/drums/salamander/OH/")
(set-sampler-index drums (string-append drum-path "kick_OH_F_9.wav") *gm-kick* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "snareStick_OH_F_9.wav") *gm-side-stick* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "snare_OH_FF_9.wav") *gm-snare* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "hihatClosed_OH_F_20.wav") *gm-closed-hi-hat* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "hihatFoot_OH_MP_12.wav") *gm-pedal-hi-hat* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "hihatOpen_OH_FF_6.wav") *gm-open-hi-hat* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "loTom_OH_FF_8.wav") *gm-low-floor-tom* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "hiTom_OH_FF_9.wav") *gm-hi-floor-tom* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "crash1_OH_FF_6.wav") *gm-crash* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "ride1_OH_FF_4.wav") *gm-ride* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "china1_OH_FF_8.wav") *gm-chinese* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "cowbell_FF_9.wav") *gm-cowbell* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "bellchime_F_3.wav") *gm-open-triangle* 0 0 0 1)
(set-sampler-index drums (string-append drum-path "ride1Bell_OH_F_6.wav") *gm-ride-bell* 0 0 0 1)

完成后可以使用如下的方式演奏我们的鼓:

1
2
3
4
;; evaluate these as you see fit!
(play-note (now) drums *gm-kick* 80 44100)
(play-note (now) drums *gm-snare* 80 44100)
(play-note (now) drums *gm-closed-hi-hat* 80 44100)

如果采样的音频文件命名有一定的规律,可以使用 external/instruments.xtm 中提供的辅助宏 load-sampler 以更简洁的方式加载采样。具体见 这篇文章

演奏乐器

Extempore 自带了一些乐器,这些乐器在 libs/core/instruments.xtm 文件中定义。

演奏单音

下面的例子将载入自带乐器,命名为 synth ,并用它奏出随机音高的声音。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;; load the instruments file
(sys:load "libs/core/instruments.xtm")
;; define a synth using the provided components
;; synth_note_c and synth_fx
(define-instrument synth synth_note_c synth_fx)
;; add the instrument to the DSP output sink closure
(bind-func dsp:DSP
(lambda (in time chan dat)
(synth in time chan dat)))
(dsp:set! dsp)
;; play a note on our synth
(play-note (now) synth (random 60 80) 80 (* 1.0 *second*))

演奏和弦

除了弹奏一个单音,也可以通过编写相应函数弹奏一个和弦。例如一个 C 和弦:

1
2
3
4
5
6
7
8
(define chord
(lambda ()
(play-note (now) synth 60 80 *second*)
(play-note (now) synth 64 80 *second*)
(play-note (now) synth 67 80 *second*)))
;; play chord
(chord)

后面将介绍使用递归形式演奏和弦

演奏一组声音

要让 Extempore 演奏一组声音从而形成完整的曲子,可以使用循环。下例演示了演奏 “Hello World” 的 ASCII 序号对应的音高:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; hello world as a list of note pitches
; transposed down two octaves (24 semitones)
(define melody (map (lambda (c)
(- (char->integer c) 24))
(string->list "Hello World!")))
; Define a recursive function to cycle through the pitches in melody
(define loop
(lambda (time pitch-list)
(cond ((null? pitch-list) (println 'done))
(else (play-note time synth (car pitch-list) 80 10000)
(loop (+ time (* *second* 0.5))
(cdr pitch-list))))))
; start playing melody
(loop (now) melody)

递归演奏音阶及和弦

在 Scheme 中,使用递归形式可以更好地控制演奏流程。

演奏一组全音阶:

1
2
3
4
;; recursive whole-tone scale
(let loop ((i 0))
(play-note (+ (now) (* i 2500)) synth (+ 60 i) 80 4000)
(if (< i 9) (loop (+ i 2))))

演奏一组主音阶:

1
2
3
4
5
6
;; recursive major scale
(let loop ((scale '(0 2 4 5 7 9 11 12))
(time 0))
(play-note (+ (now) time) synth (+ 60 (car scale)) 80 4000)
(if (not (null? (cdr scale)))
(loop (cdr scale) (+ time 5000))))

上例还带了一个时间参数 time, 因此可以进一步控制每个音高播放的时间长度:

1
2
3
4
5
6
7
;; recursive major scale with rhythm
(let loop ((scale '(0 2 4 5 7 9 11 12))
(dur '(22050 11025 11025 22050 11025 11025 44100 44100))
(time 0))
(play-note (+ (now) time) synth (+ 60 (car scale)) 80 (car dur))
(if (not (null? (cdr scale)))
(loop (cdr scale) (cdr dur) (+ time (car dur)))))

演奏一个 C 和弦:

1
2
3
4
5
6
7
8
9
10
11
;; recursive chord
(let loop ((chord '(0 4 7)))
(play-note (now) synth (+ 60 (car chord)) 80 44100)
(if (not (null? (cdr chord)))
(loop (cdr chord))))
;; we could also write this
(let loop ((scale '(0 4 7)))
(cond ((null? scale) 'finished)
(else (play-note (now) synth (+ 60 (car scale)) 80 44100)
(loop (cdr scale)))))

分解和弦:

1
2
3
4
5
6
;; for-each broken chord with volumes
(for-each (lambda (p d v)
(play-note (+ (now) d) synth p v (- 88200 d)))
(list 60 64 67)
(list 0 22050 44100)
(list 90 50 20))

这里用 for-each 而不用 map 遍历列表的原因是 map 每次会返回一个新的列表,而 for-each 不会返回新的列表,而只会触发副作用(例如发出声音)[3]

使用 callback 递归演奏

前面已经介绍了多种播放一组音高的方法。下面介绍使用 callback 函数来递归演奏一组音高。

1
2
3
4
5
6
7
8
;; plays a sequence of pitches
(define play-seq
(lambda (time plst)
(play-note time synth (car plst) 80 11025)
(if (not (null? (cdr plst)))
(callback (+ time 10000) 'play-seq (+ time 11025) (cdr plst)))))
(play-seq (now) '(60 62 63 65 67 68 71 72))

这个看起来和前面讲述的方法没什么不同,但它们确实存在一些细微的区别。为了进一步说明不同之处,我们修改一下 play-seq 函数,让它一直循环播放:

1
2
3
4
5
6
7
8
9
;; loop over a sequence of pitches indefinitely
(define play-seq
(lambda (time plst)
(play-note time synth (car plst) 80 11025)
(if (null? (cdr plst))
(callback (+ time 10000) 'play-seq (+ time 11025) '(60 62 65))
(callback (+ time 10000) 'play-seq (+ time 11025) (cdr plst)))))
(play-seq (now) '(60 62 65))

现在试着在播放的时候将 play-seq 函数里的 (60 20 65) 改为 (60 20 67) 并重新对 play-seq 求值,再改成 (60 20 67 69) 再求值。你会发现当一组音高播完后,将播放你最新修改的音高值列表。这是因为 play-seq 在 plst 会空时,会在 callback 中重新初始化该列表。

要停止播放这段音乐,可以将 play-seq 重新定义为一个空函数:

1
(define play-seq (lambda args))

我们再扩展一下 play-seq,为它添加一个长度列表 rlst :

1
2
3
4
5
6
7
8
9
10
11
12
13
;; plays a sequence of pitches
(define play-seq
(lambda (time plst rlst)
(play-note time synth (car plst) 80 (car rlst))
(callback (+ time (* .5 (car rlst))) 'play-seq (+ time (car rlst))
(if (null? (cdr plst))
'(60 62 65 69 67)
(cdr plst))
(if (null? (cdr rlst))
'(11025 11025 22050 11025)
(cdr rlst)))))
(play-seq (now) '(60 62 65 69 67) '(11025 11025 22050 11025))

注意到 rlst 和 plst 长度允许不同,两个列表是彼此独立的遍历和重新初始化的。

目前为止,演奏音乐的音量都是 80 ,我们可以修改一下程序,通过使用 random 加入随机因子,实现一个音高和音量不断变化的演奏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;; plays a random pentatonic sequence of notes with a metric pulse
(define play-seq
(lambda (time plst rlst)
(play-note time synth (car plst)
(+ 60 (* 50 (cos (* 0.03125 3.141592 time))))
(* .65 (car rlst)))
(callback (+ time (* .5 (car rlst))) 'play-seq (+ time (car rlst))
(if (null? (cdr plst))
(make-list-with-proc 4 (lambda (i) (random '(60 62 64 67 69))))
(cdr plst))
(if (null? (cdr rlst))
(make-list 4 11025)
(cdr rlst)))))
(play-seq (now) '(60 62 64 67) '(11025))

节拍和长度

到目前为止,我们使用的是 Extempore 的标准计时方式 —— 采样/秒,来控制节拍和长度。更有用的方式是使用节拍和长度。

下面这个例子用到了前面建立好的鼓乐器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
;; assuming you've set up and loaded the drums sampler as in
;; http://benswift.me/2012-10-17-loading-and-using-a-sampler.html
(bind-func dsp:DSP
(lambda (in time chan dat)
(+ (synth in time chan dat)
(organ in time chan dat)
(drums in time chan dat))))
(define drum-loop
(lambda (time dur)
(play-note time drums *gm-cowbell* 80 dur)
(callback (+ time (* .5 dur)) 'drum-loop (+ time dur) (random '(22050 11025)))))
(drum-loop (now) 11025)

我们可以将上面这段程序用更抽象化描述的时间来描述:

1
2
3
4
5
6
7
8
;; beat loop
(define drum-loop
(lambda (time dur)
(let ((d (* dur *samplerate*)))
(play-note time drums *gm-cowbell* 80 d)
(callback (+ time (* .5 d)) 'drum-loop (+ time d) (random '(0.5 0.25))))))
(drum-loop (now) 0.25)

Extempore 默认的速率是 60 拍/秒(bpm) ,我们可以将上面的程序改成 120 bpm,并加上 1/3 拍。

1
2
3
4
5
6
7
8
9
;; beat loop at 120bpm
(define drum-loop
(lambda (time dur)
(let ((d (* dur .5 *samplerate*)))
(play-note time drums *gm-cowbell* 80 d)
(callback (+ time (* .5 d)) 'drum-loop (+ time d)
(random (list (/ 1 3) 0.5 0.25))))))
(drum-loop (now) 0.25)

为了得到更明确的律动,runtime/scheme.xtm 提供了一个 make-metro 函数,该函数接受一个 tempo 参数,返回一个可以生成节拍样本的闭包。

1
2
3
4
5
6
7
8
9
10
11
12
;; create a metronome starting at 120 bpm
(define *metro* (make-metro 120))
;; beat loop
(define drum-loop
(lambda (time duration)
(println time duration)
(play-note (*metro* time) drums *gm-cowbell* 80 (*metro* 'dur duration))
(callback (*metro* (+ time (* .5 duration))) 'drum-loop (+ time duration)
(random (list 0.5)))))
(drum-loop (*metro* 'get-beat) 0.5)

从上面的例子我们可以发现:

  1. 我们通过调用 (*metro* 'get-beat) 开始我们的循环。这个语句向 metro 闭包请求返回下一个可用的节拍数值(fmod beat 1.0)。metro 一经初始化,就开始算拍子了。
  2. 时间以拍子数为单位(而不再是采样数),并且是个累加值。你可以在 extempore 的输出窗口中看到任意时刻 time 的值。
  3. (*metro* 'dur duration) 返回一个采样与当前节拍的相对长度信息。

我们可以加入一个振荡器,并使用 set-tempo 使得节奏发生变化:

1
2
3
4
5
6
7
8
9
10
11
12
;; create a metronome starting at 120 bpm
(define *metro* (make-metro 120))
;; beat loop with tempo shift
(define drum-loop
(lambda (time duration)
(*metro* 'set-tempo (+ 120 (* 40 (cos (* .25 3.141592 time)))))
(play-note (*metro* time) drums *gm-cowbell* 80 (*metro* 'dur duration))
(callback (*metro* (+ time (* .5 duration))) 'drum-loop (+ time duration)
(random (list 0.5)))))
(drum-loop (*metro* 'get-beat) 0.5)

Extempore 还提供了另一个有用的律动函数 make-metremake-metre 同样返回一个闭包,该闭包根据一个简单的查询返回 #t#f:给定一个累加的节拍数,是否当前处在某个律动上?

make-metre 包含两个参数:第一个参数是一个数值列表,第二个参数是一个分母值。例如:

  • (make-metre '(4) 1.0) 提供以 4 分音符为一拍,每小节 4 拍(即 4/4 拍);
  • (make-metre '(3) 0.5) 提供以 8 分音符为一拍,每小节 3 拍(即 3/8 拍);
  • (make-metre '(2 3) 0.5) 提供以 8 分音符为一拍,每小节先 2 拍再 3 拍(即 2/8 拍,3/8 拍,2/8 拍,3/8 拍……)。

先看看最简单的例子。以 2/8、3/8、2/8 拍的节奏演奏鼓的侧敲。

1
2
3
4
5
6
7
8
9
10
11
12
13
(define *metro* (make-metro 90))
;; a 2/8 3/8 2/8 cycle
(define *metre* (make-metre '(2 3 2) 0.5))
;; play first beat of each 'bar'
(define metre-test
(lambda (time)
(if (*metre* time 1.0)
(play-note (*metro* time) drums *gm-side-stick* 80 10000))
(callback (*metro* (+ time 0.4)) 'metre-test (+ time 0.5))))
(metre-test (*metro* 'get-beat 1.0))

我们接下来使用两个律动器,两个律动分别演奏鼓的不同部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
;; classic 2 against 3
(define *metro* (make-metro 180))
;; 3/8
(define *metre1* (make-metre '(3) .5))
;; 2/8
(define *metre2* (make-metre '(2) .5))
;; play first beat of each 'bar'
(define metre-test
(lambda (time)
(if (*metre1* time 1.0)
(play-note (*metro* time) drums *gm-side-stick* 80 10000))
(if (*metre2* time 1.0)
(play-note (*metro* time) drums *gm-snare* 60 10000))
(callback (*metro* (+ time 0.4)) 'metre-test (+ time 0.5))))
(metre-test (*metro* 'get-beat 1.0))

下面是一个更复杂(也更动听)的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
;; messiaen drum kit
(define *metro* (make-metro 140))
(define *metre1* (make-metre '(2 3 4 3 2) .5))
(define *metre2* (make-metre '(3 5 7 5 3) .5))
;; play first beat of each 'bar'
(define metre-test
(lambda (time)
(play-note (*metro* time) drums
(random (cons .8 *gm-closed-hi-hat*) (cons .2 *gm-open-hi-hat*))
(+ 40 (* 20 (cos (* 2 3.441592 time))))
(random (cons .8 500) (cons .2 2000)))
(if (*metre1* time 1.0)
(begin (play-note (*metro* time) drums *gm-snare* 80 10000)
(play-note (*metro* time) drums *gm-pedal-hi-hat* 80 100000)))
(if (*metre2* time 1.0)
(begin (play-note (*metro* time) drums *gm-kick* 80 100000)
(play-note (*metro* time) drums *gm-ride-bell* 100 100000)))
(callback (*metro* (+ time 0.2)) 'metre-test (+ time 0.25))))
(metre-test (*metro* 'get-beat 1.0))

用 Extempore 写的小苹果

最后是我写的一段《小苹果》:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
(sys:load "libs/core/instruments.xtm")
;; define a synth using the provided components
;; synth_note_c and synth_fx
(define-instrument synth synth_note_c synth_fx)
;; add the instrument to the DSP output sink closure
(bind-func dsp:DSP
(lambda (in time chan dat)
(synth in time chan dat)))
(dsp:set! dsp)
;; Define a recursive function to cycle through the pitches in melody
(define play-seq
(lambda (time plst rlst)
(let ((d (* (car rlst) .5 *samplerate*)))
(play-note time synth (+ 60 (car plst)) 80 d)
(if (and (not (null? (cdr plst))) (not (null? (cdr rlst))))
(callback (+ time (* .5 d)) 'play-seq (+ time d) (cdr plst) (cdr rlst))))))
(define sing1
(lambda () (play-seq (now) '(16 12 14 9 16 14 12 14 9)
'(0.5 0.5 0.5 0.5 0.3 0.3 0.3 0.3 0.5))))
(define sing2
(lambda () (play-seq (now) '(16 12 14 14 19 16 11 12)
'(0.5 0.5 0.5 0.5 0.3 0.3 0.5 0.5))))
(define sing3
(lambda () (play-seq (now) '(12 11 9 11 12 14 7 21 19 16 16)
'(0.3 0.3 0.5 0.3 0.3 0.4 0.5 0.3 0.3 0.5 0.5))))
(define sing4
(lambda () (play-seq (now) '(14 12 16 14 7 9 12 9)
'(0.3 0.5 0.4 0.5 0.5 0.5 0.5 0.5))))
(sing1)
(sing2)
(sing3)
(sing4)

如果有兴趣,可以观看演奏过程的视频:

深入阅读

由于篇幅关系,本文并没有详细介绍 Scheme 和 xtlang 的语法细节,也略过了乐器的编写和音级(pitch classes)的使用。读完这篇文章后,感兴趣的读者可以继续阅读以下几篇文章:

  1. Extempore 官方文档
  2. Scheme 教程
  3. 如何编写乐器
  4. 音级

Extempore 自带的相关范例也是不错的学习资源:

  • examples/core/audio_101.xtm
  • examples/core/polysynth.xtm
  • examples/core/fmsynth.xtm
  • examples/external/electrofunk.xtm
  • examples/external/audio_player.xtm
  • examples/external/convolution_reverb.xtm

  1. 对于 cyber-physical 这个词,我一直想不出更好的翻译。cyber 意为 “计算机的”,而 physical 则意为 “物理的,人体的”。从作者的设计意图上来看, physical 应该更接近“人体”的意思。而直接翻译成“人机编程”又显得很 low :编程不就是人和计算机之间的交互吗?(我想这也是为什么作者不直接命名为 “man-machine”)最后我决定翻译为机体编程,有计算机和人体混合的含义。

  2. 更多命名约定可参考 xtlang naming conventions

  3. 副作用在 Lisp 中不是一个贬义词,而是求值的附加效果。例如 Emacs Lisp 的副作用常常是对文本缓冲区的控制。