探险Lua——使用Lua播放音乐

举报
橘座 发表于 2020/02/12 02:39:21 2020/02/12
【摘要】 今天,我们要用Lua来播放音乐。Lua并没有内建的声效库,不过其他语言实现的倒有不少。我们今天要用到Lua的一个很强大的功能——Lua的C语言接口,我们会用这种方式来控制一个开源的声效库。有一些伟大的冒险家已经走过这条路了。他们利用Lua的表意性来描述程序的逻辑,用C语言来做性能要求高的部分,并且用到了这一章即将讲到的技巧来把Lua和C黏结在一起。《Adobe lightroom》,《魔兽世...

今天,我们要用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)》


【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。