512字节JavaScript的诺基亚Composer

2020-10-17 04:54:44

那些曾经拥有像3310或3210这样的旧诺基亚手机的人可能还记得它可以直接在手机键盘上编写你自己的铃声的奇妙功能。通过安排笔记和停顿,你可以用手机扬声器发出的嘟嘟声结束一首流行的曲子,此外,你还可以和你的朋友们分享它!如果你错过了那个时代,下面是它的样子:

不为所动?嗯,相信我,那时候真的很酷,特别是如果你喜欢音乐的话。

诺基亚作曲家使用的音乐符号和格式被称为RTTTL(铃声文本传输语言),它仍然在业余爱好者中广泛使用,在Arduino等平台上播放单声道曲调。

RTTTL只允许你为一个“声音”谱曲,音符只能按顺序播放,没有和弦或复调。然而,这种限制是该格式的杀手级特性,因为它易于编写、易于阅读、易于解析和易于播放。

在本文中,我们将尝试用JavaScript构建一个RTTTL播放器,并使用一些代码高尔夫和数学技巧来使其尽可能地小。

RTTTL有一个正式的语法-它由三个部分组成:歌曲名称、歌曲默认设置,如节奏(BPM)、默认八度和默认音符持续时间。然而,我们将模仿诺基亚作曲家本身的行为,只解析旋律部分,并将BPM Tempo作为单独的输入。歌曲名称和其他默认值将被排除在范围之外。

旋律只是一个由逗号分隔的音符/休止符序列,并带有一些可选的空格。每个音符由持续时间(2/4/8/16/32/64)、音符音高(c/d/e/f/g/a/b)、可能的锐号(#)和八度数字(1-3,因为只支持三个八度)组成。

最简单的方法是使用regexp。新的浏览器提供了一个非常方便的matchAll函数,该函数返回字符串中所有匹配项的集合:

Const play=s=>;{(m of s.。MatchAll(/(\d*)?(\.?)(#?)([a-g-])(\d*)/g){//m[1]是可选的音符时长//m[2]是音符时长中的可选点//m[3]是可选的尖号,是的,它在音符之前//m[4]是音符本身//m[5]是可选的八度数}};

关于每个音符,首先要弄清楚的是如何将其转换成频率。当然,我们可以为笔记的所有可能的七个字母创建一个哈希图,但是由于笔记字母是连续的-应该更简单地将它们作为数字来处理。对于每个便条字母,我们都会得到一个字符码(ASCII码)。对于‘A’,它将是0x41,对于‘a’,它将是0x61。对于‘B/b’,应该是0x42/0x62;对于‘C/c’,应该是0x43/0x63,依此类推:

//';k';是注释的ASCII代码://A..g=0x41..0x47//a..g=0x61..0x67设k=m[4]。CharCodeAt();

我们可能会忽略上面的比特,只使用k&;7作为音符索引(a=1,c=2,&;mldr,g=7),但是下一步呢?下一部分令人不快地棘手,这要归功于音乐理论。如果我们有7个音符,那就有12个了。这些尖音/降音符是以非常不均匀的方式夹在正常音符之间的:

A#C#D#F#G#A#<;-黑键A B|C D E F G A B|C<;-白键-+--+---k&;7:1 2|3 4 5 6 7 1 2|3-+--+---note:9 10 11|0 1 2 3 4 5 6 7 8 9 10 11|0。

正如我们在这里看到的,八度音阶内的音符索引比(k&;7)音符代码增长得更快。而且,它不是线性增加的:E和F之间或B和C之间的“距离”是1个半音,而不是其他音符之间的2个。

直观地说,我们可以尝试将(k&;7)乘以12/7(有12个半音和7个音符):

注:a b c d e f g(k&;7)*12/7:1.71 3.42 5.14 6.85 8.57 10.28 12.0

如果我们查看不含小数部分的这些数字,我们会立即注意到它们已经是非线性的,与我们预期的非常相似:

注:a b c d e f g(k&;7)*12/7:1.71 3.42 5.14 6.85 8.57 10.28 12.0 Floor((k&;7)*12/7):1 3 5 6 8 10 12。

..但不是我们预期的方式-“半音”距离应该在B/C和E/F之间,而不是C/D之间。让我们试试其他系数(下划线表示半音):

注:a b c d e f Gloor((k&;)*1.8):1 3 5 7 9 10 12-Floor((k&;)*1.7):1 3 5 6 8 10 11-Floor((k&;)*1.6):1 3 4 6 8 9 11-Floor((k&;)*1.6):1 3 4 6 8 9 11。7)*1.5):1 3 4 6 7 9 10。

显然,1.8和1.5的值不匹配-第一个值只有一个半音,而最后一个值太多了。另外两个(1.6和1.7)实际上相当不错:1.7的结果是G-A-BC-D-EF的主要规模,1.6的结果是A-B-CD-E-F-G的主要规模,这正是我们所需要的!

现在我们需要稍微调整这些值,使C为0,D为2,E为4,F为5,依此类推。我们应该将其移位4个半音,但是减去4会使A音符低于C音符,因此,如果该值超出八度,我们就加8并计算模12:

设n=(k&;7)*1.6)+8)%12;//A B C D E F G A B C...//9 11 0 2 4 5 7 9 11 0...。

我们还应该考虑m[3]正则表达式群捕捉到的尖锐符号。如果存在-我们应该将音调值增加1个半音:

//我们使用!!m[3],如果m[3]是';#';-,则计算结果为`true`//并且由于`+`符号被转换为`1`。//如果m[3]未定义-它变成`false`,因此变成`0‘:设n=(k&;7)*1.6)+8)%12+!!M[3];

最后,我们应该使用正确的八度。八度已经作为数字存储在m[5]regexp组中,音乐理论告诉我们每个八度是12个半音,所以我们可以将八度数字乘以12,然后加上音符的值:

N是音符索引0..35,其中0是最低八度的C,//12是中八度的C,35是最高八度的B。设n=(k&;7)*1.6)+8)%12+//注释索引0..11!!M[3]+//半音0/1 m[5]*12;//八度数。

如果有人写10或1000作为八度数字怎么办?这可能会导致超音速音调。我们应该只允许为这些参数设置正确的值。将一个数字限制在另外两个数字之间通常被称为“钳制”,现代的JS有一个特殊的函数Math.clip(x,low,high),但是在大多数浏览器中还不能使用该函数。最简单的替代方法是使用:

但是,由于我们试图尽可能地缩短代码,我们可以重新发明轮子,避免使用数学函数。我们使用x=0缺省值,以使钳位也可以使用未定义的值:

CLAMP=(x=0,a,b)=>;(x<;a&;&;(x=a),x>;b?B:x);夹具(0,1,3)//=>;1夹具(2,1,3)//=>;2夹具(8,1,3)//=>;3夹具(未定义,1,3)//=>;1。

我们希望BPM作为参数传递给out play()函数,我们只需要验证它:

现在,要计算音符应该持续多长时间(以秒为单位),我们可以获得它的音乐持续时间(整个/半/四分之一/&;mldr),它存储在m[1]regexp捕获组中,并使用以下公式:

NOTE_Duration=m[1];//可以是1,2,4,8,16,32,64//因为BPM是";节拍/分钟";,所以//BPM/4应该是";整音/秒*NOTE_Duration;而BPM/60/4将是";整音//每秒";:WOLL_NOTES_PER_Second=bpm/240;Duration=1/(Whole_Notes_Per_Second*NOTE_Duration);

//假设默认音符时长为4:时长=240/bpm/CLAMP(m[1]||4,1,64);

支持虚线持续时间也很好,它可以将当前音符长度增加50%。我们有一个捕获组m[2],它的值可以是一个点。或未定义。应用我们对锐利标志使用的相同技巧,我们可以得到:

//!!如果是点,则M[2]为1,否则为0//1+!![M2]/2对于普通票据为1,对于点票据持续时间为1.5=240/bpm/CLAMP(m[1]||4,1,64)*(1+!!M[2]/2);

现在我们可以计算每个音符的音符编号和持续时间了。是时候研究WebAudio API来实际播放曲调了。

我们只需要整个WebAudio API中的3个部分:音频上下文、获取声波的振荡器和静音/非静音的增益节点。我将使用方波振荡器,让它听起来很像老式手机的可怕蜂鸣器:

//osc->;Gain->;AudioContext LET AUDIO=new(AudioContext()||webkitAudioContext);LET Gain=AUDIO。CreateGain();让OSC=AUDIO。CreateOscillator();Osc.。类型=#39;正方形;OSC。连接(增益);增益。连接(音频。目的地);OSC。Start();

这个代码本身还不会产生任何音乐,但是当我们解析我们的RTTTL旋律时-我们可以告诉WebAudio播放哪个音符、何时播放、以什么频率播放以及播放多长时间。

所有WebAudio节点都有一个特殊的方法setValueAtTime,该方法调度一个事件来修改值,例如节点的频率或增益级别。

如果您还记得本文前面的部分,我们已经将音符ASCII代码存储为k,音符索引存储为n,音符持续时间以秒为单位。现在,对于每个音符,我们可以执行以下操作:

T=0;//当前时间计数器,以秒为单位(m of.){//....我们在这里解析笔记...。//音符频率计算为(F*2^(n/12)),//其中n为音符索引,F为n=0的频率//可以使用C2=65.41C3=130.81。C2稍微短了一点。OSC.。频率。SetValueAtTime(65.4*2**(n/12),t);//将增益调到100%。除了注释[a-g],`k`也可以是`-`,//是休息符。`-`在ASCII中为0x2d。因此,与其他备注字母不同,//(k&;8)对于备注是0,对于REST是8。如果我们颠倒`k`,那么//(~k&;8)对于音符为8,对于REST为0。对于音符,移位3将为//((~k&;8)>;>;3)=1,对于休止符为0。收获。收获。SetValueAtTime((~k&;8)>;>;3,t);//按音符时长t=t+时长创建时间标记;//关闭音符增益。收获。SetValueAtTime(0,t);}。

就这样。我们的play()例程现在可以播放以RTTTL表示法编写的完整旋律。以下是完整的代码,只有几个小地方,例如使用v作为setValueAtTime的快捷方式,或使用单字母变量(C=上下文,z=振荡器,因为它嗡嗡作响,g=增益,q=bpm,c=CLAMP):

C=(x=0,a,b)=>;(x<;a&;&;(x=a),x>;b?B:x);//钳位功能(a<;=x<;=b)play=(s,bpm)=>;{C=new AudioContext;(z=C.。CreateOscillator())。连接(g=C。CreateGain())。连接(C.。目的地);z。类型=#39;正方形;z。Start();t=0;v=(x,v)=>;x。SetValueAtTime(v,t);//setValueAtTime(m of s.。MatchAll(/(\d*)?(\.?)([a-g-])(#?)(\d*)/g){k=m[4]。CharCodeAt();//备注ASCII[0x41..0x47]或[0x61..0x67]n=0|(k&;7)*1.6)+8)%12+!!M[3]+12*c(m[5],1,3);//注索引[0..35]v(z.。频率,65.4*2**(n/12);v(g.。增益,(~k&;8)/8);t=t+240/bpm/(c(m[1]||4,1,64))*(1+!!M[2]/2);v(g.。Ain,0);}};//用法:play(';8c 8d 8e 8f 8g 8a 8b 8c2';,120);

缩写为Terser时,此代码占用417个字节。这仍然低于512字节的预期阈值,所以我们为什么不添加一个stop()函数来中断播放:

C=0;//以零STOP开始初始化音频上下文EXT C=_=>;C&;&;C。Close(C=0);//零参数函数用`_`代替`()`可以节省一个字节:)。

这仍然大约是445字节,如果您将这段代码粘贴到您的开发人员控制台中-您将能够通过调用JS函数play()和stop()来播放RTTTL并停止播放。

然而,我认为为作曲家添加一些UI会改善作曲体验。在这一点上,我建议忘掉代码高尔夫,为RTTTL旋律做一个小编辑器,不保存任何字节,使用普通的HTML和CSS,并包括仅用于回放的缩小脚本。

我不会把代码放在这里,它很无聊,相反,你可能会在GitHub上找到所有的代码。此外,您还可以在此处尝试现场演示:https://zserge.com/nokia-composer/。

如果你的缪斯女神今天离开了你,而你今天又不想写音乐,那就试试现有的几首歌,享受熟悉的嘟嘟声吧:

好好享受吧!啊,顺便说一下,如果你真的在那里创作了什么-请分享URL(整首歌和BPM存储在URL的散列部分,所以保存/分享你的歌曲就像复制或书签链接一样简单。

我希望你喜欢这篇文章。你可以在Github、Twitter或通过RSS订阅,并向其投稿。