探险Lua——使用Lua播放音乐
今天,我们要用Lua来播放音乐。Lua并没有内建的声效库,不过其他语言实现的倒有不少。我们今天要用到Lua的一个很强大的功能——Lua的C语言接口,我们会用这种方式来控制一个开源的声效库。
有一些伟大的冒险家已经走过这条路了。他们利用Lua的表意性来描述程序的逻辑,用C语言来做性能要求高的部分,并且用到了这一章即将讲到的技巧来把Lua和C黏结在一起。《Adobe lightroom》,《魔兽世界》和《愤怒的小鸟》都用到了Lua,它们或者用Lua做内部功能,或者用Lua做面向终端用户的扩展语言。
制作音乐
用电脑做音乐有很多种方式。今天我们用一个C++库制作MIDI(Musical Instrument Digital Interface,乐器数字接口)。一开始,我们会写一些C++代码来展示Lua与其他语言的结合能力。然后我们很快就会回到Lua的世界里,把所有东西都包装到具有表意性的API里。
良友相伴
在用软件制作MIDI音乐这条路上有很多良友相伴。Topher Cyll在《Practical Ruby Projects》这本书里使用到了类似的技巧。Giles Bowkett在Topher的基础上用Ruby和CoffeeScript写了一些算法来写歌。
我们的目标没有那么大,只需要能够用最简洁的Lua API播放已经存在的音乐就好了。
为历险做准备
在开始今天的历险之前,你需要一些装备。你需要安装以下的程序,并确保它们都可以执行:适用于你的操作系统的C++编译器、用于构建C++的CMake工具、Lua的C头文件和库(应该是与你的Lua一起安装的)、RtMidi声效库、一个MIDI合成器软件,这样你才能听到音乐
不同的操作系统的安装方式不尽相同。下面是针对Windows、Mac和Linux的安装步骤。
Windows
安装Visual Studio Express 2013。
下载RtMidi的源代码,用Visual Studio打开其中的.sln文件,把库构建出来。
下载并安装Windows版本的CMake。
安装VirtualMIDISynth播放器。
Mac
确保你安装了C++编译器,比如Xcode命令行工具。
安装Homebrew包管理器。
添加C sound项目的源代码:brew tap kunstmusik/csound。
安装Lua、CMake,还有RtMidi:brew install lua cmake rtmidi。
下载并安装SimpleSynth MIDI播放器。
Linux
下面的安装步骤是针对Ubuntu Linux的,如果你使用其他Linux系统则需要做相应的调整。
使用Synaptic包管理工具,添加universe repository以便获取更多的包。
安装各种编译器,以及Lua、CMake和RtMidi:sudo apt-get install build-essential lua5.2 lua5.2-dev cmake rtmidi。
安装并且配置好一个Linux可用的MIDI合成器。Linux对MIDI的支持不太完备,不过还是可以用一个叫作ZynAddSubFX的合成器再加上一个叫作padsp的帮助程序来做:sudo apt-get install zynaddsubfx pulseaudio-utils。
创建项目
我们的目的是要创建一个叫作play的命令行程序,它可以播放我们指定给它的任何歌曲。我们即将发明一种用Lua来写的乐谱。整个系统包括3个部分:
1.一个C++程序,它会开启一个Lua解释器并执行一段由音乐家(就是你!)提供的脚本。
2.一个Lua函数,它可以给MIDI设备发送消息;用C++来写的,但是可以从Lua中调用。
3.一个Lua库,它提供的语法让谱曲变得更容易。
小小解释器 我们从Lua的解释器开始。在你项目的目录下创建一个叫作play.cpp的文件,写入以下代码:
lua/day3/a/play.cpp extern "C"{#include "lua.h"#include "lauxlib.h"#include "lualib.h"}
这段代码可以把Lua的运行时和辅助库引入到C++中。extern "C"为编译器和链接器指明引入的是C代码,而不是C++。
现在,添加一个main()函数,这是命令行的C程序的入口点:
lua/day3/a/play.cppint main(int argc, const char* argv[]) { lua_State* L = luaL_newstate(); luaL_openlibs(L); luaL_dostring(L, "print('Hello world!')"); lua_close(L); return 0; }
我们用luaL_newstate()函数创建一个Lua解释器。默认的解释器的设计原则秉承轻量级的原则,所以要引入Lua的标准库需要调用另一个函数——luaL_openlibs()。
一旦解释器被加载之后,我们就可以通过调用luaL_dostring()给它发送一些Lua代码。在我们完工之后,传入的代码会是一段用Lua写成的歌曲。现在,我们只是向命令行打印一些文字。
构建项目
现在我们可以构建项目了。这需要两个步骤:
(1)用CMake创建一个工程文件;
(2)使用make或者是Visual Studio编译C程序。
只需要给CMake提供一些对你项目的描述就可以了。把下面的内容写入一个叫作CMakeLists.txt的文件里:
lua/day3/a/CMakeLists.txtcmake_minimum_required (VERSION 2.8)project (play)add_executable (play play.cpp)target_link_libraries (play lua)
如果你的Lua头文件不在系统默认位置,那你或许需要添加一行include_directories(),比如这样:
<strong>lua/day3/a/CMakeLists.txt</strong>include_directories(/usr/local/include)
现在,在你的项目目录下执行以下命令来告诉CMake去创建工程文件:
$ cmake .
在Mac和Linux下,这条命令会创建一个Makefile,你可以通过make命令来用它构建项目。在Windows系统中,CMake会创建一个.sln文件,你可以用Visual Studio加载该文件并构建。现在就去做这一步吧。
项目构建好之后,在你的项目目录下会出现一个叫作play或者play.exe的文件。如果你使用Windows系统,这样来执行你的程序:
C:\day3> play.exeHello world!
在Mac和Linux系统中,执行以下命令:
$ ./play Hello world!
你看到命令行的输出了吧?太好了!现在,我们来创作音乐吧。
添加声效
首先我们需要引入RtMidi库。把以下的代码添加到C++代码顶部的extern "C"代码块的右括号后面:
lua/day3/b/play.cpp#include "RtMidi.h"static RtMidiOut midi;
RtMidiOut对象就是我们和MIDI生成器交互的接口。我们在这里仅仅是把它放在一个全局变量里。这种数据通常会放到Lua的注册表里,不过对于我们当前的目的来说那就有些小题大做了。
现在,我们把main()函数与MIDI合成器连接起来:
lua/day3/b/play.cpp int main(int argc, const char* argv[]) { ➤ if (argc < 1) { return -1; } ➤ ➤ unsigned int ports = midi.getPortCount(); ➤ if (ports < 1) { return -1; } ➤ midi.openPort(0); lua_State* L = luaL_newstate(); luaL_openlibs(L); ➤ lua_pushcfunction(L, midi_send); ➤ lua_setglobal(L, "midi_send"); ➤ ➤ luaL_dofile(L, argv[1]); lua_close(L); return 0; }
首先,我们用RtMidi的API去寻找正在运行着的合成器(如果找不到就退出程序)。接着,我们启动Lua的解释器。然后,我们注册一个用来播放乐谱的C++函数。最后,我们执行Lua代码并退出解释器。
我们是如何把C和C++代码与Lua连接起来的?Lua使用一个简单的栈模型与C代码交互。我们把函数的存储地址入栈,然后调用Lua内置的lua_setglobal()函数把函数赋值给一个Lua的变量。
你或许注意到我们把luaL_dostring()
换成了luaL_doFile()
。这行代码可以从文件中加载Lua代码(我们从命令行获取用户输入的文件名;比如play song.lua
)。这样,我们就不需要在每次Lua代码有改变的时候都去重新编译C++代码了。
让这里有声音吧
现在就要有音乐了!要播放一个音符,我们需要给MIDI合成器发送两个MIDI消息:一个Note On消息和一个Note Off消息。MIDI标准给每一个消息编了号,并且规定每个消息接受两个参数:音符和速率。
这就意味着midi_send()
这个Lua函数要接受三个参数:消息编号,以及两个数字型参数。当执行以下Lua代码时:
midi_send(144, 60, 96)
144、60和96这三个数字会被入栈,然后开始执行C++函数。我们需要根据这些参数在栈内的位置来获取它们。在Lua里栈顶的序号是−1,对应最后入栈的那个数字,也就是96。
由于Lua是动态类型的,入栈的值有可能是任何类型的:数字、字符串、table、函数等。不过由于我们是完全能够控制.lua脚本中的代码的,所以我们不会把任何除了数字之外的值压入栈中,这样在C++代码里就可以只处理数字值了。把下面的代码加入C++文件中,放在main()
函数上面:
lua/day3/b/play.cppint midi_send(lua_State* L) { double status = lua_tonumber(L, -3); double data1 = lua_tonumber(L, -2); double data2 = lua_tonumber(L, -1); // ...rest of C++ function here... return 0; }
如果C++函数需要把数据传递给Lua的话,我们可以把数据入栈并返回一个正数。在上面的代码中,我们返回了0来代表没有入栈任何数据。
更新工程文件
接下来要做的就是把刚才入栈的数字转换成RtMidi能够读取的格式,并把它们传递给合成器。把下面的代码写入你的midi_send()函数的返回语句前面:
lua/day3/b/play.cppstd::vector<unsigned char> message(3); message[0] = static_cast<unsigned char>(status); message[1] = static_cast<unsigned char>(data1); message[2] = static_cast<unsigned char>(data2); midi.sendMessage(&message);
现在我们的工程文件需要链接Lua和RtMidi。把CMakeLists.txt
文件中targetlink libraries()
改成下面这样:
lua/day3/b/CMakeLists.txt target_link_libraries (play lua RtMidi)
重新构建项目。趁它正在运行,我们来写一个简短的Lua测试程序来播放一个音符:中央C,时长1秒钟。把以下的代码写入one_note_song.lua
:
lua/day3/b/one_note_song.lua NOTE_DOWN = 0x90NOTE_UP = 0x80VELOCITY = 0x7ffunction play(note) midi_send(NOTE_DOWN, note, VELOCITY) while os.clock() < 1 do end midi_send(NOTE_UP, note, VELOCITY)endplay(60)
试一下吧!先把MIDI合成器启动,然后执行你的程序:
./play one_note_song.lua
你应该可以听到中央C播放一秒钟。
从音符到歌曲
一曲只有一个音符的歌对于Tenacious D乐队来说或许还好。不过我们还是把目标定得高一些吧。
首先,写歌的时候如果能够不用MIDI音符编号会容易得多,如果能用接近音乐记谱法的方式来写就好了。我们来找一些大家都耳熟能详的乐谱吧,比如《生日快乐歌》或者是《大家早上好》(它诞生于《生日快乐歌》有版权声明之前四十多年)。
这首歌的前几个音符是D、E、D、G和升F,它们的长度大部分是一样的(四分之一音节),只有升F例外(二分之一音节,是其他音符的二倍长)。这首歌是用中音C演奏的,中音C在科学记谱法中是4号。
我们可以在Lua里用几种不同的方式表示这些音符。现在,我们先简单地用字符串来表示(比如Fs代表升F),后面跟着音度(比如4),最后是音长(比如h代表半音符)。
把下面的歌曲写入good_morning_to_all.lua
:
lua/day3/b/good_morning_to_all.luanotes = { 'D4q', 'E4q', 'D4q', 'G4q', 'Fs4h'}
我们需要能够把这些字符串转换到MIDI的音符编号和长度。我们播放其他歌曲时也需要同样的代码,所以把以下代码写入一个新文件:
lua/day3/b/notation.lualocal function parse_note(s) local letter, octave, value = string.match(s, "([A-Gs]+)(%d+)(%a+)") if not (letter and octave and value) then return nil end return { note = note(letter, octave), duration = duration(value) }end
首先,我们使用Lua的string.match()函数来确保输入的字符串符合我们期望的格式。如果输入合法,我们就调用其他的helper函数来计算MIDI音符和用秒表示的长度。最后,我们返回一个包含着音符和长度的table。
第一个helper函数note()
只是做简单的加法和乘法运算。把下面的代码写到notation.lua
文件的最上端:
lua/day3/b/notation.lua local function note(letter, octave) local notes = { C = 0, Cs = 1, D = 2, Ds = 3, E = 4, F = 5, Fs = 6, G = 7, Gs = 8, A = 9, As = 10, B = 11 } local notes_per_octave = 12 return (octave + 1) * notes_per_octave + notes[letter] end
要想翻译用秒表示的音长(比如把q翻译为四分之一音节),我们需要知道歌曲的节拍。我们默认选用每分钟一百拍的节奏,如果需要的话,歌曲可以选择覆盖这个值。把以下的代码写到note()函数的后面:
lua/day3/b/notation.lualocal tempo = 100local function duration(value) local quarter = 60 / tempo local durations = { h = 2.0, q = 1.0, ed = 0.75, e = 0.5, s = 0.25, } return durations[value] * quarterend
代码中的table中有一个名为ed的元素,这是附点八分音符,它是一般八分音符的1.5倍长。我们稍后会在另一首歌里用到它。
要遍历table并播放那些音符很简单。回到good_morning_to_all.lua,把下面的函数写入其中:
lua/day3/b/good_morning_to_all.lua scheduler = require 'scheduler' notation = require 'notation' function play_song() for i = 1, #notes do local symbol = notation.parse_note(notes[i]) ➤ notation.play(symbol.note, symbol.duration) end end
现在我们需要同时用到音符编号和长度,那就得修改play()
函数了。我们需要发送Note On消息,等待一段时间,然后发送Note Off消息。
我们怎么才能在等待的时候不阻塞程序的运行呢?等一下,我们在第二天不是遇到过类似的问题吗?去把我们写的调度器的代码复制到当前项目里。然后,把下面的代码写入notation.lua
:
lua/day3/b/notation.lualocal scheduler = require 'scheduler'local NOTE_DOWN = 0x90local NOTE_UP = 0x80local VELOCITY = 0x7flocal function play(note, duration) midi_send(NOTE_DOWN, note, VELOCITY) scheduler.wait(duration) midi_send(NOTE_UP, note, VELOCITY)end
``` lua/day3/b/notation.luareturn { parse_note = parse_note, play = play }
做个小结,notation.lua现在包含以下函数。
1.私有的helper函数,note()
和duration()
。
2.公开的parse_note()
函数。
3.公开的play()
函数,以及几个局部变量。
4.描述该Lua模块的返回语句。
要使用调度器的话,得给歌曲的代码加一个步骤。我们必须要在goodmorning to_all.lua的结尾处启动事件循环:
lua/day3/b/good_morning_to_all.lua scheduler.schedule(0.0, coroutine.create(play_song)) scheduler.run()
准备好听听你的音乐了吗?
./play good_morning_to_all.lua
现在我们已经能够编写简单的歌曲了,下面我们来做些难度稍高的吧。
多声道
我们自制的Lua音乐程序看起来还挺不错的。不过有几件事在接下来给更长的歌曲编码的时候会变得很麻烦:
我们没有支持多声道的API。
所有的音符都需要用引号括起来。
我们真正想要做到的是能像下面这样写歌:
song.part{ D3q, A2q, B2q, Fs2q } song.part{ D5q, Cs5q, B4q, A4q } song.go()
我们希望以上的歌曲定义在播放时可以让两个part同时发声。多亏了我们的调度器,我们可以处理同时播放的问题。把下面的代码写到notation.lua里,就放在最后的返回语句之前:
lua/day3/b/notation.lualocal function part(t) local function play_part() for i = 1, #t do play(t[i].note, t[i].duration) end end scheduler.schedule(0.0, coroutine.create(play_part))end
这个函数接受一个音符数组,叫作t,该函数内定义一个叫作play_part()的函数,它可以依序播放参数数组内的音符,最后我们把它安排进调度器里,只要顶层歌曲调用run()函数就可以播放了。
那接下来就只剩下如何解决音符必须要放到括号里的问题了。如果想不写括号的话,音符就必须得是全局变量。Lua把全局变量放在一个叫作_G的table里。我们需要做的就是运用第2天学到的metatable技巧来修改table查找的方式:
lua/day3/b/notation.lua local mt = { __index = function(t, s) local result = parse_note(s) ➤ return result or rawget(t, s) end } setmetatable(_G, mt)
上面定义的函数在每次全局变量查找时都会被调用,而不仅是查找音符变量时才用到。如果我们在代码里写错变量的名字,那也会导致这个函数被调用。在查找不到音符的时候就去查找_G中的值。我们对rawget()
函数的调用绕过了自定义的查找代码,这样就不会因为一个不存在的变量名而导致无限循环了。
现在剩下的就是几个工具函数了。我们需要让音乐家可以设置节奏,还需要给scheduler.run()提供一个包装,这样歌曲就无须显式加载scheduler模块了:
lua/day3/b/notation.lualocal function set_tempo(bpm) tempo = bpmendlocal function go() scheduler.run()end
不要忘记修改模块的返回语句来让其中包含新写的公开函数:
lua/day3/b/notation.luareturn { parse_note = parse_note, play = play, part = part, set_tempo = set_tempo, go = go}
现在我们就做好了写更复杂的歌曲的准备了。
Canon in D
你可以在Petrucci项目中找到很多公开的乐谱。我选用了Pachelbel的Canon in D。
这是其中的一小部分:
lua/day3/b/canon.lua song = require 'notation' song.set_tempo(50) song.part{ D3s, Fs3s, A3s, D4s, A2s, Cs3s, E3s, A3s, B2s, D3s, Fs3s, B3s, Fs2s, A2s, Cs3s, Fs3s, G2s, B2s, D3s, G3s, D2s, Fs2s, A2s, D3s, G2s, B2s, D3s, G3s, A2s, Cs3s, E3s, A3s, } song.part{ Fs4ed, Fs5s, Fs5s, G5s, Fs5s, E5s, D5ed, D5s, D5s, E5s, D5s, Cs5s, B4q, D5q, D5s, C5s, B4s, C5s, A4q } song.go()
如果你把以上代码写入canon.lua并运行./play canon.lua,你就会听到我最爱的乐曲之一,而且还是多个部分同时播放呢!
本文节选自《七周七语言(卷2)》
- 点赞
- 收藏
- 关注作者
评论(0)