目录 项目介绍 环境准备 项目结构和代码 从单个音符到乐曲 多声道乐曲播放 项目介绍说明: 本文是根据 七周七语言(卷2) 中的一个
Lua
示例项目略加修改而来.
这个项目通过 Lua
调用一个用 C++
实现的 MIDI
接口库 RtMIDi
来控制一个 MIDI合成器
播放自定义格式的乐谱,来演示 Lua
跟 C
之间的代码交互.
首先用 C++
作为宿主程序,把 Lua
解释器嵌入其中,接着用 C++
封装了一个可供 Lua
脚本调用的 C++
函数 mIDi_send
,这个函数通过调用 RtMIDi
库中的 API
向 MIDI合成器
发送控制命令来播放音乐,而音乐的来源则是我们用 Lua
自定义格式的乐谱,由 Lua
将其解析转换为 MIDI 合成器
能够识别的格式.
这个项目是跨平台的,可以同时支持 windows/macOS/linux
平台,本文只提供 macOS
上的实现,其他两个平台也很简单,其中 Lua
部分的代码不需要改变.
需要安装以下环境
包管理器brew
; 编译工具 XCode
或 gcc
; C sound
项目的源码跟 RtMIDi
; Lua
和 CMake
; macOS
下的 MIDI
合成器: SimpleSynth
我的环境上只缺 C sound
项目,RtMIDi
以及 SimpleSynth
,前两个用 brew
安装,命令如下:
C sound
项目的源代码 Air:mIDi admin$ brew tap kunstmusik/csoundUpdating Homebrew...==> auto-updated Homebrew!Updated 2 taps (homebrew/core and homebrew/cask).==> New Formulaeazure-storage-cpp i386-elf-binutils [email protected] [email protected] shellz umfluxctl i386-elf-gcc mesa [email protected] sourcedocs==> Updated FormulaebDW-gc ? dartsim hebcal mitIE secc-ares ? ......==> Deleted Formulaecorebird [email protected] [email protected] [email protected] netHack4 [email protected] taylor tcptrackError: Failed to import: /usr/local/Homebrew/library/Taps/benswift/homebrew-extempore/extempore-llvm341.rbextempore-llvm341: undefined method `sha1‘ for #<Class:0x000000011189d728>==> TapPing kunstmusik/csoundcloning into ‘/usr/local/Homebrew/library/Taps/kunstmusik/homebrew-csound‘...remote: Enumerating objects: 7,done.remote: Counting objects: 100% (7/7),done.remote: Compressing objects: 100% (7/7),done.remote: Total 7 (delta 0),reused 3 (delta 0),pack-reused 0Unpacking objects: 100% (7/7),done.Tapped 3 formulae (34 files,28.1KB).Air:mIDi admin$安装
RtMIDi
Air:mIDi admin$ brew install rtmIDi==> Downloading https://homebrew.bintray.com/bottles/rtmIDi-3.0.0.high_sIErra.bottle.tar.gz######################################################################## 100.0%==> Pouring rtmIDi-3.0.0.high_sIErra.bottle.tar.gz?? /usr/local/Cellar/rtmIDi/3.0.0: 8 files,196.6KBAir:mIDi admin$
而 SimpleSynth
可以直接到它的官网去下载: SimpleSynth,下载回来后把它运行起来,用它来充当 MIDI 合成器
.
环境准备 OK,接下来就正式开始项目了.
项目结构我们这个项目很简单,就是 3
部分:
C++
宿主程序 play.cpp
,创建 Lua
解释器并执行自定义格式的乐谱; 用 Lua
写的模块,负责对解析乐谱,跟 MIDI 合成器
交互; 用 Lua
写的自定义格式的乐谱; 首先为项目创建一个目录 mIDi
,把所有的项目代码都放在这里.
C++
宿主程序 play.cpp
在 mIDi
目录下创建一个 C++
文件 play.cpp
,内容如下:
extern "C"{#include "lua.h"#include "lauxlib.h"#include "lualib.h"}int 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;}代码分析
基础函数库: 其中 #include "lua.h"
引入 Lua
的基础函数库,它提供如下基础函数:
Lua
环境的函数; 调用 Lua
函数的函数; 读写环境中的全局变量的函数; 注册供 Lua
语言调用的新函数的函数; ... 辅助函数库: #include "lauxlib.h"
引入辅助函数库,它使用 lua.h
提供的基础 API
来提供更高层次的抽象,特别是对标准库用到的相关机制进行抽象.
标准函数库: #include "lualib.h"
引入标准函数库,所有的标准库都被组织成不同的包.
用
lua_State* L = luaL_newstate();
创建一个 Lua
解释器,然后用
luaL_openlibs(L);
打开标准库,之后就可以用
luaL_dostring(L,"print(‘Hello World!‘)");
给 Lua
解释器发送一些 Lua
代码让它去执行.
接着我们就可以用 CMake
来构建项目了,在 mIDi
目录下创建一个名为 CMakeLists.txt
的文件,内容如下:
cmake_minimum_required (VERSION 2.8)project (play)add_executable (play play.cpp)target_link_librarIEs (play lua)include_directorIEs (/usr/local)link_directorIEs ("/usr/local")
然后执行 cmake
Air:mIDi admin$ cmake .-- Configuring done-- Generating done-- Build files have been written to: /Users/admin/code-staff/lua+c/mIDiAir:mIDi admin$
接着执行 make
,提示找不到 lua.h
Air:mIDi admin$ make[ 50%] linking CXX executable playld: library not found for -lluaclang: error: linker command Failed with exit code 1(use -v to see invocation)make[2]: *** [play] Error 1make[1]: *** [CMakefiles/play.dir/all] Error 2make: *** [all] Error 2Air:mIDi admin$
既然找不到 lua
库的路径,那么看看它在哪里:
Air:mIDi admin$ find /usr/local -name "liblua*"/usr/local/lib/liblua5.3.4.dylib/usr/local/lib/liblua.a/usr/local/Cellar/lua/5.2.4_3/lib/liblua.5.2.dylib/usr/local/Cellar/lua/5.2.4_3/lib/liblua.5.2.4.dylib/usr/local/Cellar/lua/5.2.4_3/lib/liblua.dylib/usr/local/Cellar/lua/5.3.4_3/lib/liblua.5.3.dylib/usr/local/Cellar/lua/5.3.4_3/lib/liblua.5.3.4.dylib/usr/local/Cellar/lua/5.3.4_3/lib/liblua.dylib/usr/local/Cellar/lua/5.3.4_3/lib/liblua.aAir:mIDi admin$
在 CMakeList.txt
中增加路径说明:
cmake_minimum_required (VERSION 2.8)project (play)add_executable (play play.cpp)target_link_librarIEs (play lua)include_directorIEs (/usr/local/Cellar/lua/5.3.4_3/)link_directorIEs ("/usr/local/Cellar/lua/5.3.4_3/")
再次执行 make
,结果还是同样的错误,因为对 CMake
不太熟悉,于是查了很多资料,试验了很多方法,结果还是不行,后来一想,算了,不用 CMake
了,反正这个项目也很简单,就这么一个 C++
文件,直接用命令行编译吧,命令行如下:
Air:mIDi admin$ g++ play.cpp -o play -I/usr/local -L/usr/local -lluaAir:mIDi admin$Air:mIDi admin$ ./playHello World!Air:mIDi admin$
结果顺利通过,OK,终于可以进行下一步了
引入 RtMIDi 库接着就要引入 RtMIDi
库,对 MIDI合成器
进行 *** 作了,首先修改 play.cpp
代码如下:
extern "C"{#include "lua.h"#include "lauxlib.h"#include "lualib.h"}#include "RtMIDi.h"static RtMidiout mIDi;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_dostring(L,"print(‘Hello World!‘)"); luaL_dofile(L,argv[1]); lua_close(L); return 0;}代码分析
这两行代码引入 RtMIDi
库,其中 RtMidiout
对象就是我们后续的程序中用来跟 MIDI 合成器
进行交互的接口,将其放入一个全局变量 mIDi
中,后面就可以通过这个全局变量 mIDi
来引用 RtMIDi
库的函数:
#include "RtMIDi.h"static RtMidiout mIDi;
接着通过命令行输入的参数个数argc
来判断用户是否输入正确,若否则直接退出.
下面就是对 RtMIDi
库的函数来对 MIDI 合成器
进行 *** 作,使用了两个函数:
mIDi.getPortCount()
mIDi.openPort()
关于这两个函数的详细定义可以在 RtMIDi
官网教程 RtMidiOut Class Reference 查到.
它们具体的工作就是寻找正在运行中的 MIDI 合成器
(也就是我们之前运行起来的 SimpleSynth
).
然后是这两行代码:
lua_pushcfunction(L,mIDi_send);lua_setglobal(L,"mIDi_send");
首先用 lua_pushcfunction
注册一个用来播放音乐的 C++
函数 mIDi_send
,函数 lua_pushcfunction
会获取一个指向函数 mIDi_send
的指针(也就是 L
),然后在 Lua
中创建一个 function
类型,代表待注册的函数 mIDi_send
. 一旦把这个函数类型的值压入 Lua
栈中完成注册,这个 C++
函数 mIDi_send
就可以像其他 Lua
函数一样被调用了.
然后再用 lua_setglobal
把这个函数类型的值赋给全局变量 mIDi_send
,完成这两步,我们就可以在 Lua
脚本中使用新函数 mIDi_send
了.
注意: 第一个
mIDi_send
是在C++
中定义的函数,第二个mIDi_send
是提供给Lua
使用的函数名,这两个名字可以不一样.
最后我们把代码行:
luaL_dostring(L,"print(‘Hello World!‘)");
换成了:
luaL_dofile(L,argv[1]);
因为函数 luaL_dofile
可以从文件中加载 Lua
代码,我们从命令行获取用户输入的 Lua
文件名,例如:
play song.lua
这样就可以灵活地把乐曲放在 song.lua
中,而不需要每次改写 Lua
乐曲时都去重新编译 C++
代码了.
要想在 MIDI合成器
中播放一个音符,需要给它发送两个 MIDI
消息:
Note On
消息 Note Off
消息 MIDI
标准给每个消息编了号,并规定每个消息接受 2
个参数:
这样我们的 mIDi_send
函数就需要使用 3
个参数:
例如如下 Lua
代码就代表一个 Note On
消息,音符为 60
,速率为 96
:
mIDi_send(144,60,96)
执行这行代码后,144
,60
,96
这 3
个数字会被入栈,然后开始执行 C++
函数. 按照 Lua
编写 C API
的约定,我们可以根据这些参数在栈内的位置来获取它们. Lua
栈顶的索引是 -1
,对应着最后入栈的数字 96
.
前面我们虽然注册了 mIDi_send
函数,但是还没有编写具体的代码,根据 MIDI 合成器
对消息格式的要求,可以写出如下的 mIDi_send
函数定义代码:
int mIDi_send(lua_State* L){ double status = lua_tonumber(L,-3); double data1 = lua_tonumber(L,-2); double data2 = lua_tonumber(L,-1); std::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); return 0;}
代码分析记得将其放在
play.cpp
中main
函数的前面.
我们知道 Lua
通过一个简单的栈模型来实现跟 C/C++
代码的交互,所以下面这 3
行代码就是把我们提供的 3
个 MIDI合成器
要用到的参数入栈:
double status = lua_tonumber(L,-3);double data1 = lua_tonumber(L,-2);double data2 = lua_tonumber(L,-1);
然后要把刚才入栈的数字转换成 RtMIDi
能够读取的格式,并用 mIDi.sendMessage
函数把它们传递给 MIDI合成器
,下面这几行代码就是做这些工作的:
std::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);
说明: 这是
C++
形式的写法,实际上对于mIDi.sendMessage
函数,RtMIDi
还提供了一个C
形式的原型,我们也可以按照C
的形式去写这段代码.
因为我们在代码中引入了 RtMIDi
库,所以需要在 CMakeLists.txt
文件中增加相关说明 以便链接器能够正确把 RtMIDi
库链接进去,如下:
target_link_librarIEs (play lua RtMIDi)
不过对我来说,需要修改的就是在编译命令行上增加 lRtMIDi
再重新执行,如下:
g++ play.cpp -o play -I/usr/local -L/usr/local -llua -lRtMIDi
一切顺利,编译通过.
自定义格式乐谱前面说了,我们第一次只打算播放一个音符,我们把这个简单的乐谱放在 Lua
文件 one_note_song.lua
中,其代码如下:
NOTE_DOWN = 0x90NOTE_UP = 0x80VELociTY = 0x7ffunction play(note) mIDi_send(NOTE_DOWN,note,VELociTY) while os.clock() < 2 do end mIDi_send(NOTE_UP,VELociTY)endplay(60)代码分析
首先,定义消息编号跟速率,接着写一个用来播放的函数 play
,在其中调用我们事先写好的 C++
函数 mIDi_send
来播放,中间的这行代码:
while os.clock() < 2 do end
用来控制播放时间,我们这里选择了 2
秒.
确保 SimpleSynth
正在运行,然后执行如下命令:
Air:mIDi admin$ ./play one_note_song.lua Air:mIDi admin$
就会听到中音C
持续播放 2
秒钟.
前面说过,我们的项目分 3
部分,不过我们只实现了其中的 1
(C++宿主程序
),接下来我们就把剩下的两部分完成.
首先,我们用 Lua
来定义一种乐谱格式,创建一个新文件 good_morning_to_all.lua
,内容如下:
notes = { ‘D4q‘,‘E4q‘,‘D4q‘,‘G4q‘,‘Fs4h‘}
这是一个 Lua
的 table
,它代表一首歌曲的乐谱,使用一种类似于 ABC记谱法
的格式来标识乐谱,具体来说就是用 C,D,E,F,G,A,B
来表示 1,2,3,4,5,6,7
,再加上一些额外的符号,可以完整地表示一段乐谱.
我们的自定义格式乐谱中每个字符串表示 3
个部分,以 D4q
为例:
D
,可以有 C
,Cs
,D
,Ds
,E
,F
,Fs
,G
,Gs
,A
,As
,B
; 音度: 4
,又叫音程,确定乐曲基准音,可以有 0
~12
; 音长: q
,可以有 h
,q
,ed
,e
,s
. 而 Fs4h
中的 Fs
表示 升F
.
我们需要有一个乐谱解析函数,来把我们乐谱中的这些字符串解析转换成 MIDI
的音符编号跟长度,也就是 mIDi_send(144,96)
函数中的 音符
和 速率
参数,我们新建一个文件 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]endlocal 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] * quarterendlocal 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代码分析
首先分析函数 parse_note(s)
,它用来实现从乐谱到 MIDI
数据的解析转换.
代码行:
local letter,"([A-Gs]+)(%d+)(%a+)")
使用 Lua
的 string.match
函数进行模式匹配和捕获,遇到 D4q
这样的字符串,首先它会进行如下匹配:
D
匹配到模式 ([A-Gs]+)
; 将 4
匹配到 (%d+)
; 将 q
匹配到 (%a+)
, 接着它会返回匹配成功的子串,也就是返回 D
,4
,将其分别赋给局部变量 letter
,octave
,value
,最后再用 letter
和 octave
构造 MIDI音符
,用 value
构造MIDI速率
,也就是这段返回代码:
return { note = note(letter,duration = duration(value)}
在这段代码中用到两个新函数 note(letter,octave)
和 duration(value)
,我们继续分析这两个函数.
函数 note(letter,octave)
首先定义了一个音阶表 notes
,里面根据每个音名跟 MIDI音符
的对应关系设置一个数值,再定义一个 notes_per_octave
,最后根据公式来计算实际的 MIDI音符
数值:
return (octave + 1) * notes_per_octave + notes[letter]
这样我们就可以根据 音名
和 音度
得到 MIDI音符
.
最后是函数 duration(value)
,它根据音长来计算 MIDI速率
,同样定义了一个表 durations
,里面用不同的字符表示不同的音长设置,还定义默认节拍 tempo
,作为计算基准,最终根据公式:
return durations[value] * quarter
计算得到用秒表示的 MIDI速率
.
这样,MIDI 合成器
需要的参数就都准备好了,接下来就是播放相关的代码,需要修改 good_morning_to_all.lua
,遍历其中乐谱表 notes
的每个音符,新增代码如下:
scheduler = require ‘scheduler‘notation = require ‘notation‘function play_song() for i = 1,#notes do local symbol = notation.parse_note(notes[i]) print("note:",symbol.note," duration:",symbol.duration) notation.play(symbol.note,symbol.duration) endendscheduler.schedule(0.0,coroutine.create(play_song))scheduler.run()代码分析
函数 play_song()
所做的就是遍历乐谱表 notes
,将其中的每个字符串解析转换为 note
和 duration
,然后传递给函数 notation.play
.
这里使用了一个新的调度库 scheduler
,是利用 Lua
的 协程
实现的,关于 协程
的内容相对来说要复杂一些,所以这里我们只使用,不对其做详细讲解,如果想要了解 协程
,可以参考我以前写过的一篇介绍 协程
的文章 从零开始写一个武侠冒险游戏-5-使用协程.
而 notation.lua
中的新增代码如下:
local scheduler = require ‘scheduler‘local NOTE_DOWN = 0x90local NOTE_UP = 0x80local VELociTY = 0x7f增加在结尾位置的
local function play(note,duration) mIDi_send(NOTE_DOWN,VELociTY) scheduler.wait(duration) mIDi_send(NOTE_UP,VELociTY)endreturn { parse_note = parse_note,play = play,}
留心一下就会发现,这个版本我们用这行代码:
scheduler.wait(duration)
取代了原来的:
while os.clock() < 2 do end
使用 scheduler
库的好处就是在等待的时候不会阻塞程序的运行.
这里附上调度库 scheduler.lua
的代码:
-- scheduler.lualocal pending = {}local function sort_by_time(array) table.sort(array,function(e1,e2) return e1.time < e2.time end)endlocal function remove_first(array) result = array[1] array[1] = array[#array] array[#array] = nil return resultendlocal function schedule(time,action) pending[#pending +1] = { time = time,action = action } sort_by_time(pending)endlocal function wait(seconds) coroutine.yIEld(seconds)endlocal function run() while #pending > 0 do while os.clock() < pending[1].time do end local item = remove_first(pending) local _,seconds = coroutine.resume(item.action) -- print("seconds:",seconds) if seconds then later = os.clock() + seconds schedule(later,item.action) end endendreturn { schedule = schedule,run = run,wait = wait}
完整的 notation.lua
的代码如下:
-- notation.lualocal scheduler = require ‘scheduler‘local NOTE_DOWN = 0x90local NOTE_UP = 0x80local VELociTY = 0x7flocal function note(letter,duration = duration(value) }endlocal function play(note,}
完整的 good_morning_to_all.lua
代码如下:
-- good_morning_to_all.luascheduler = require ‘scheduler‘notation = require ‘notation‘notes = { ‘D4q‘,‘Fs4h‘}function play_song() for i = 1,coroutine.create(play_song))scheduler.run()
乐曲播放的代码基本完工,试试效果:
./play good_morning_to_all.lua
听到了悦耳的乐曲声!
多声道乐曲播放截至目前为止,我们的项目从无到有,已经实现了乐曲播放,不过似乎还有些不太完美,比如只支持单声道,还有就是我们自定义格式的乐谱中的每个音符都要用引号引起来,写起来比较麻烦,所以我们接下来希望解决这两个问题.
那么我们希望自定义格式的乐谱写成这个样子:
song.part{ D3q,A2q,B2q,Fs2q,}song.part{ D5q,Cs5q,B4q,A4q,}song.go()
多声道播放就是同时播放多个声部,类似于合唱,好在我们有调度器 scheduler
,可以很容易实现这一点,把以下代码放入 notation.lua
中:
local function part(t) local function play_part() for i = 1,#t do print("note:",t[i].note,"duration:",t[i].duration) play(t[i].note,t[i].duration) end end scheduler.schedule(0.0,coroutine.create(play_part))endlocal function set_tempo(bpm) tempo = bpmendlocal function go() scheduler.run() endreturn { parse_note = parse_note,part = part,set_tempo = set_tempo,go = go,}代码分析
函数 part(t)
使用音符数组 t
,在其中定义了一个用于遍历播放 t
的函数 play_part
,我们把它加入调度器 scheduler
中,只要通过新增的函数 go
来调用 scheduler.run()
就可以播放了,通过调度器非常简单就实现了多声道播放.
最后是解决乐谱中每个音符都必须使用引号的问题,其实这个问题有多种解决方法,不过书中使用了最直接粗暴的一种,就是使用 Lua
的元表,将每个音符都设为全局变量,具体代码如下(这段代码也要放在 notation.lua
中):
local mt = { __index = function(t,s) local result = parse_note(s) return result or rawget(t,s) end}setMetatable(_G,mt)代码分析
以上代码重新定义了对 Lua
全局表 _G
中全局变量查找的方式 __index
,优先从函数 parse_note(s)
表返回的表中查找,其余不是音符的全局变量则由 rawget(t,s)
提供查找结果.
最后我们使用一个完整的自定义格式的乐谱,是一首卡农,两个声部,新建文件 canon.lua
,代码如下:
-- canon.luasong = require ‘notation‘song.set_tempo(50)song.part{ D3s,Fs3s,A3s,D4s,A2s,Cs3s,E3s,B2s,D3s,B3s,Fs2s,G2s,G3s,D2s,}song.part{ Fs4ed,Fs5s,G5s,E5s,D5ed,D5s,Cs5s,D5q,C5s,B4s,}song.go()
因为我们写的 C++宿主程序
缺少对 Lua
脚本的错误处理代码,所以在最开始调试的时候遇到不少问题,其中一个就是因为把乐谱中的大写音符写成小写结果导致 C stack overflow
,所以一定要确保你的输入没有任何错误.
最后执行:
./play canon.lua
接下来就可以静静欣赏多声部卡农了.
参考七周七语言(卷2)
How can I build a C program that embeds Lua?
cmake 添加头文件目录,链接动态、静态库
将 Mac OS X 系统的 C、C++ 编译器从默认的 Clang 切换到 GCC
Lua C Stack Overflow 错误代码汇总
While installing on OSX Sierra via gcc-6,keep having “FATAL:/opt/local/bin/../libexec/as/x86_64/as: I don‘t understand ‘m‘ flag!” error
Cmake知识----编写CMakeLists.txt文件编译C/C++程序
as don‘t understand ‘m‘ flag
ABC记谱法
音程(音乐术语)
以上是内存溢出为你收集整理的用 Lua 控制 MIDI 合成器来播放自定义格式乐谱全部内容,希望文章能够帮你解决用 Lua 控制 MIDI 合成器来播放自定义格式乐谱所遇到的程序开发问题。
如果觉得内存溢出网站内容还不错,欢迎将内存溢出网站推荐给程序员好友。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)