08.21 编程,居然还可以用来学吉他!

编程,居然还可以用来学吉他!

编程,居然还可以用来学吉他!

开过光的序

当一个民谣小哥抱着吉他哼唱着《情非得已》时,他右手扫着音孔处的琴弦,左手变换着按着琴颈处的琴弦,一段简单的弹唱便看起来有模有样。在不看脸不看唱功的情况下,是什么原理才赋予这位小哥如此风骚的魅力呢?

这就是吉他伴奏。

而他只是一个吉他初学者,还没办法给歌曲编配伴奏,只好从网上找来吉他谱,按照里面的标识来进行弹奏。他找到了下面这样的谱子:

编程,居然还可以用来学吉他!

这是一个典型的吉他弹唱谱该有的样子,它可以被分成四个部分:

  • 和弦指法:用于标记该小节内的和弦名以及对应的按弦指法。
  • 六线谱:它是专门属于吉他的谱子,六条横线至上而下分别对应吉他的一弦到六弦,横线上添加各种符号来标记右手的弹奏方式。
  • 简谱:这里是数字简谱,配以各种符号来描述歌曲的旋律与节奏。
  • 歌词:嗯,就是歌词。

对于初学者,吉他入门的坎儿在于左手的指法,当时我记下了大多数和弦的指法图,左手指尖的磨出的茧也是起了褪,褪了起,身为乐理渣的我终有一天疑惑了,问号三连:

1. 这个和弦为什么叫这个名字?

2. 这个和弦为什么是这个指法?

3. 同一和弦在吉他上到底有多少种不同的指法?

本文将基于基本的乐理知识,用代码推导计算出以上问题的答案,并将其结果可视化。

一、从一个单音说起

心虚的声明:外行人基于自己的理解强行解释乐理,望专业人士轻喷

声音因物体振动而产生,每一个不同频率(即不同音高)的声响都可以称之为一个单音,但人耳的辨音能力有限,故将人耳能清晰分辨的最小的音高间隔称为半音;

相隔半音的两个音的频率比值为2的12次方根。

为什么是这个值,这就得提到十二平均律。

音乐界老前辈经过大量的听力实践后,发现例如do到高音do这个音程作为一个循环听起来最和谐,并且这高音do与do的频率比率刚好是2,在保证单音之间跨度和谐、而且能较清晰地辨听的情况下,将这个音程按频率比划分成了12等份,这与中国的五声音阶(宫商角徵羽)和西洋的七声音阶存在相互映照的关系,如下图(这里我暂时用数字标记十二平均律音程上的每个音):

编程,居然还可以用来学吉他!

类似do与高音do之间的关系在七声音阶里被称为八度;

也就是说一个音与它对应高八度的音之间的跨度便是一个音程,它们的频率比为1:2。

1(do)与2(re)之间是一个全音的跨度,而3(mi)与4(fa)、7(si)与1.(高音do)之间是一个半音的跨度,一个全音跨度就相当于两个半音跨度,可以看出1(do)与2(re)之间还夹了一个音,我们称它为#1(升do)或者说b2(降re)。

理解了这些后,便可以用代码实现一个单音类:

1. 首先来确定一种单音的书写形式

可以借用简谱的标记方式,数字1、2、3、4、5、6、7,分别代表唱名的do、re、mi、fa、sol、la、si;

当这个音升半调时,在数字的前面加上#,例如#1(升do),降半调时,在数字前面加上b,例如b1(降do);

当标记一个音的高八度音时,在数字的右侧加一个“点号”,例如1.(高音do),#2.(高音升re)(因为字符串没法像简谱那样在数字顶部加点号),当标记一个音的低八度音时,在数字的左侧加一个“点号”,例如.1(低音do),.b2(低音降re);

2. 构建单音类

// 检测数据类型的公用方法

functionis(data){

returnfunction(type){

returnObject.prototype.toString.call(data)===`[object${type}]`;

}

}

// 单音类,用于音的映射查询与音高的改变,同时可标记记录其在吉他上的位置

classTone{

constructor(toneString='1',string,fret){

// 所有唱名数组

this.syllableMap=['do','re','mi','fa','sol','la','si'];

// 音程

this.keyMap=['1',['#1','b2'],'2',['#2','b3'],'3','4',['#4','b5'],'5',['#5','b6'],'6',['#6','b7'],'7'];

//所有调名

this.intervalMap=['C',['#C','bD'],'D',['#D','bE'],'E','F',['#F','bG'],'G',['#G','bA'],'A',['#A','bB'],'B'];

// 单音的字符串表示

this.toneString=toneString;

// 单音的字符串表示(去除八度标记)

this.toneNormal=toneString.replace(/\\./g,'');

// 数字音

this.key=toneString.replace(/\\.|b|#/g,'');

// 唱名

this.syllableName=this.syllableMap[+this.key-1];

// 降半调标记

this.flat=toneString.match('b')?'b':'';

// 升半调标记

this.sharp=toneString.match('#')?'#':'';

letoctave_arr=toneString.split(this.key);

letoctave_flat=octave_arr[0].toString().match(/\\./g);

letoctave_sharp=octave_arr[1].toString().match(/\\./g);

// 八度度数

this.octave=(octave_sharp?octave_sharp.length:0)-(octave_flat?octave_flat.length:0);

// 吉他按弦位置

this.position={

// 第几弦

string:string,

// 第几品格

fret:fret

};

}

// 获取某个音在音程上的位置

findKeyIndex(keyString){

returnthis.keyMap.findIndex((item)={

if(is(item)('Array')){

returnitem.includes(keyString);

}elseif(item===keyString){

returntrue;

}else{

returnfalse;

}

});

}

// 音高增减,num为增或减的半音数量

step(num){

letkeyString=this.flat+this.sharp+this.key;

letlen=this.keyMap.length;

letindex=this.findKeyIndex(keyString);

if(index-1){

num=+num;

// 计算改变音高后的音在音程上的位置

letnextIndex=parseInt(index+num,0);

letoctave=this.octave;

if(nextIndex=len){

letindex_gap=nextIndex-len;

octave+=Math.floor(index_gap/len)+1;

nextIndex=index_gap%len;

}elseif(nextIndex0){

letindex_gap=nextIndex;

octave+=Math.floor(index_gap/len);

nextIndex=index_gap%len+len;

}

letnextKey=this.keyMap[nextIndex];

// 计算并添加高低八度的记号

letoctaveString=newArray(Math.abs(octave)).fill('.').join('');

lettoneString='';

if(!is(nextKey)('Array')){

toneString=(octave0?octaveString:'')+nextKey+(octave0?octaveString:'');

returnnewthis.constructor(toneString,this.position.string,this.position.fret+num);

}else{

// 可能得到两个音高一样但标记方式不一样的音

returnnextKey.map((key)={

returnnewthis.constructor((octave0?octaveString:'')+key+(octave0?octaveString:''),this.position.string,this.position.fret+num);

});

}

}else{

returnnull;

}

}

}

有了这个单音类后,后续可以借用它来方便地对比两个音之间的跨度,并且可以通过构建吉他每根弦的初始音,通过step方法推导出吉他其他任意位置的音高。

执行示例:

创建一个1(do)的单音实例

编程,居然还可以用来学吉他!

单音1(do),往高跨5个半音,得到单音4(fa);往高跨6个半音,得到两个音#4(升fa)与b5(降sol),这两个音处于同一音高,本质相同,只是标记方式不一样。

编程,居然还可以用来学吉他!

二、和弦命名推导

1. 什么是和弦

编程,居然还可以用来学吉他!

由此白话提炼和弦的三个要素:

(1)由三个或三个以上的音构成;

(2)音之间有跨度关系(三度或非三度);

(3)音之间要从低到高排列。

由此我画了一张图:

编程,居然还可以用来学吉他!

一个音程上的12个音可以像时钟的刻度那样排列,顺时针方向代表音的从低到高;然后我们将“时针”、“分针”、“秒针”在不重叠且相互有一定间隔的情况下随意拨弄,把他们指向的音顺时针连起来,就可能构成了一个三个音组成的和弦(同理更多音组成的和弦就相当于再往里加指针)。

这样一看,便能发现这更像是一个排列组合问题,拿三个音的组合来说,从12个音里面任意挑3个音(不排序),会有220种情况,但这里面并不都是和弦;和弦和弦,顾名思义,听起来得和谐得不难听,这开始更像是人们的主观意识判断,但随着音乐知识体系的成熟,和弦也会有一套公认的标准,变得向数学公式那样有迹可循。

细想一下,一个和弦好不好听,带什么感情色彩,取决于组成音的相互映衬关系,也就是音之间的相互音高间隔,隔得太近会别扭,隔得太远也别扭,那就得取个适中的,这个适中就是三度;

三度又分为大三度与小三度

大三度:两个全音的跨度,即4个半音的跨度。

小三度:一个全音加一个半音的跨度,即3个半音的跨度。

C调下的C和弦组成音如下:

编程,居然还可以用来学吉他!

对照上图那个刻度盘可数出来:

1(do)与3(mi)中间还夹了#1/b2、2、#2/b3这3个音,共4个半音的跨度;

3(mi)与5(sol)中间还夹了4、#4/b5这2个音,共3个半音的跨度;

那么像这样组成的和弦就成为大三和弦。

2. 常见和弦标记规则

和弦类型组成标记大三和弦大三度 + 小三度

小三和弦小三度 + 大三度m增三和弦大三度 + 大三度aug减三和弦小三度 + 小三度dim大小七和弦(属七和弦)大三和弦+ 小三度7或Mm7大大七和弦(大七和弦)大三和弦+ 大三度maj7或M7小小七和弦(小七和弦)小三和弦+ 小三度m7或mm7小大七和弦小三和弦+ 大三度mM7减七和弦减三和弦+ 小三度dim7半减七和弦减三和弦+ 大三度m7-5增属七和弦增三和弦+ 减三度7#5或M7+5增大七和弦增三和弦+ 小三度aug7或Maj7#5

加音和弦与指定和弦根音相对复杂些,暂不讨论。

3. 和弦根音

和弦组成音中的第一个音为和弦的根音,也叫基础音,可以根据当前的调式和某和弦的根音来判断该和弦的初始名称,例如在C调下,根音与和弦名的对照关系如下:

ⅠⅡⅢⅣⅤⅥⅦ1234567CDEFGAB

通俗点说相当于,在某调下,一个和弦的根音为该调的1(do)时,那它就叫某和弦(额外标记根据音之间的三度关系再添加),例如:

C调下:

根音为1(do)构成的和弦名为C;

根音为2(re)构成的和弦名为D;

D调下:

根音为1(do)构成的和弦名为D;

根音为1(do)构成的和弦名为E;

B调下:

根音为1(do)构成的和弦名为B;

根音为2(do)构成的和弦名为C;

4. 和弦完整名称计算

基于以上的乐理规则,可以实现如下推导和弦名的类:

//和弦名称推导classChordName{constructor(chordTone){//实例化一个单音类做工具,用来计算音与各种标记的映射关系this.toneUtil=newTone();}//获取两个音的间隔跨度getToneSpace(tonePre,toneNext){lettoneSpace=this.toneUtil.findKeyIndex(toneNext)-this.toneUtil.findKeyIndex(tonePre);returntoneSpace=toneSpace0?toneSpace+12:toneSpace;}//大三度isMajorThird(tonePre,toneNext){returnthis.getToneSpace(tonePre,toneNext)===4;}//小三度isMinorThird(tonePre,toneNext){returnthis.getToneSpace(tonePre,toneNext)===3;}//增三度isMajorMajorThird(tonePre,toneNext){returnthis.getToneSpace(tonePre,toneNext)===5;}//减三度isMinorMinorThird(tonePre,toneNext){returnthis.getToneSpace(tonePre,toneNext)===2;}//大三和弦isMajorChord(chordTone){returnthis.isMajorThird(chordTone[0],chordTone[1])this.isMinorThird(chordTone[1],chordTone[2]);}//小三和弦misMinorChord(chordTone){returnthis.isMinorThird(chordTone[0],chordTone[1])this.isMajorThird(chordTone[1],chordTone[2]);}//增三和弦augisAugmentedChord(chordTone){returnthis.isMajorThird(chordTone[0],chordTone[1])this.isMajorThird(chordTone[1],chordTone[2]);}//减三和弦dimisDiminishedChord(chordTone){returnthis.isMinorThird(chordTone[0],chordTone[1])this.isMinorThird(chordTone[1],chordTone[2]);}//挂四和弦isSus4(chordTone){returnthis.isMajorMajorThird(chordTone[0],chordTone[1])this.isMinorMinorThird(chordTone[1],chordTone[2]);}//大小七和弦/属七和弦7/Mm7isMajorMinorSeventhChord(chordTone){if(chordTone.length4)returnfalse;returnthis.isMajorChord(chordTone)this.isMinorThird(chordTone[2],chordTone[3]);}//小大七和弦mM7isMinorMajorSeventhChord(chordTone){if(chordTone.length4)returnfalse;returnthis.isMinorChord(chordTone)this.isMajorThird(chordTone[2],chordTone[3]);}//大七和弦maj7/M7isMajorMajorSeventhChord(chordTone){if(chordTone.length4)returnfalse;returnthis.isMajorChord(chordTone)this.isMajorThird(chordTone[2],chordTone[3]);}//小七和弦m7/mm7isMinorMinorSeventhChord(chordTone){if(chordTone.length4)returnfalse;returnthis.isMinorChord(chordTone)this.isMinorThird(chordTone[2],chordTone[3]);}//减七和弦dim7isDiminishedSeventhChord(chordTone){if(chordTone.length4)returnfalse;returnthis.isDiminishedChord(chordTone)this.isMinorThird(chordTone[2],chordTone[3]);}//半减七和弦m7-5isHalfDiminishedSeventhChord(chordTone){if(chordTone.length4)returnfalse;returnthis.isDiminishedChord(chordTone)this.isMajorThird(chordTone[2],chordTone[3]);}//增属七和弦7#5/M7+5isHalfAugmentedSeventhChord(chordTone){if(chordTone.length4)returnfalse;returnthis.isAugmentedChord(chordTone)this.isMinorMinorThird(chordTone[2],chordTone[3]);}//增大七和弦aug7/Maj7#5isAugmentedSeventhChord(chordTone){if(chordTone.length4)returnfalse;returnthis.isAugmentedChord(chordTone)this.isMinorThird(chordTone[2],chordTone[3]);}//获取音对应的根音和弦名getKeyName(key){letkeyName=this.toneUtil.intervalMap[this.toneUtil.findKeyIndex(key)];if(is(keyName)('Array')){keyName=/b/.test(key)?keyName[1]:keyName[0];};returnkeyName;}//计算和弦名getChordName(chordTone){letrootKey=chordTone[0];//和弦的字母名letchordRootName=this.getKeyName(rootKey);//和弦字母后面的具体修饰名letsuffix='...';letsuffixArr=[];//三音和弦的遍历方法及对应修饰名letchord3SuffixMap=[{fn:this.isMajorChord,suffix:''},{fn:this.isMinorChord,suffix:'m'},{fn:this.isAugmentedChord,suffix:'aug'},{fn:this.isDiminishedChord,suffix:'dim'},{fn:this.isSus4,suffix:'sus4'}];//四音和弦的遍历方法及对应修饰名letchord4SuffixMap=[{fn:this.isMajorMinorSeventhChord,suffix:'7'},{fn:this.isMinorMajorSeventhChord,suffix:'mM7'},{fn:this.isMajorMajorSeventhChord,suffix:'maj7'},{fn:this.isMinorMinorSeventhChord,suffix:'m7'},{fn:this.isDiminishedSeventhChord,suffix:'dim7'},{fn:this.isHalfDiminishedSeventhChord,suffix:'m7-5'},{fn:this.isHalfAugmentedSeventhChord,suffix:'7#5'},{fn:this.isAugmentedSeventhChord,suffix:'aug7'}];//三音和弦if(chordTone.length===3){suffixArr=chord3SuffixMap.filter((item)={returnitem.fn.bind(this,chordTone)();});suffix=suffixArr.length0?suffixArr[0].suffix:suffix;}else{//四音和弦suffixArr=chord4SuffixMap.filter((item)={returnitem.fn.bind(this,chordTone)();});suffix=suffixArr.length0?suffixArr[0].suffix:suffix;}//拼接起来得到完整的和弦名returnchordRootName+suffix;}}

运行示例:

编程,居然还可以用来学吉他!

三、和弦指法推导

1. 指法图

一个完整的吉他和弦指法图的例子如下,右边对照为真实的吉他:

编程,居然还可以用来学吉他!

2. 吉他弦上音的分布

我从网上抠来了这张带着历史气息的彩图:

编程,居然还可以用来学吉他!

可以观察到,同样一个音,在吉他弦上的位置可以有许多个;而简单的和弦的组成音也就三四个,所以要想一下子从这些纵横的格子里寻出某个和弦所有可能的指法,同时还要考虑实际指法的各种约束:

比如你左手能用上的只有不超过5根手指头而弦有6根,但食指是可以使用大横按按多根弦的,但大横按只能按在该指法的最低品位上;还得考虑指法按弦后是包括了和弦里所有的音,同时相邻两弦的音不能一样...

诸如此类,想要一下子心算出来所有可能的结果,怕是为难我胖虎了。

不过这个很适合用递归算法解决。

3. 指法推导

为此专门构建一个类,在初始化的时候使用之前写的单音类,算出吉他弦上所有位置的音。之后就可以通过this.toneMap[tring][fret]的形式直接获得该位置的音,例如this.toneMap[1][3]获取1弦3品的音。

//吉他和弦指法推导类classGuitarChord{constructor(){//暂定的吉他的最大品格数this.fretLength=15;//构建1到6弦的初始音this.initialTone=[newTone('3.',1,0),newTone('7',2,0),newTone('5',3,0),newTone('2',4,0),newTone('.6',5,0),newTone('.3',6,0)];//用于吉他上所有位置对应的音this.toneMap=[];//从1到6弦,从品位数的低到高,依次计算每个位置的音for(letstring=1;string=this.initialTone.length;string++){this.toneMap[string]=[];for(letfret=0;fret=this.fretLength;fret++){this.toneMap[string].push(this.initialTone[string-1].step(fret));}}}}

给它加上一个公用的单音位置搜寻方法:

//在指定的品格数范围内,查找某个音在某根弦的音域下所有的品格位置/**@paramkey搜寻的音(字符串形式)*@paramtoneArray音域数组,即某根弦上所有单音类按顺序组成的数组*@paramfretStart搜寻的最低品格数*@paramfretEnd搜寻的最高品格数*/findFret(key,toneArray,fretStart,fretEnd){key=key.replace(/\\./g,'');letfretArray=[];fretStart=fretStart?fretStart:0;fretEnd=fretEnd?(fretEnd+1):toneArray.length;for(leti=fretStart;ifretEnd;i++){if(is(toneArray[i])('Array')){lettoneStringArray=toneArray[i].map((item)={returnitem.toneNormal;});if(toneStringArray.includes(key)){fretArray.push(i);}}else{if(toneArray[i].toneString.replace(/\\./g,'')===key){fretArray.push(i);}}}returnfretArray;}

接下来是核心的循环递归算法,先构思下大致的递归的流程:

(1)指定从1弦开始,启动递归。(递归入口)

(2)指定了某弦后,循环遍历和弦的组成音,计算是否有音落在该弦指定的品位范围内,如果没有,返回false;如果有,转步骤(3)。

(3)先保存该音与它的按弦位置,当前位置最终有效取决于,当且仅当在它后面的所有弦也是能找到按弦位置的有效解,如果该弦是第6弦,返回true,递归结束(递归出口),否则转步骤(4);

(4)当前结果最终的有效性=当前临时结果有效性(true)下一根弦是否存在有效解(此时已转至步骤(3))。若当前结果最终有效,返回true;若无效,回退pop出之前在该弦保存的结果。

最后实现还需考虑相邻两弦音不能相同,另外为了便于回溯整体结果,在单次的结果保存时,添加了指向上一次结果的指针pre。

//递归遍历范围内的指定和弦的所有位置组合/**@paramstringIndex当前遍历到的弦的序号*@paramtoneIndex上一根弦使用的音的序号(用于相邻的两根弦的音不重复)*@paramfretStart遍历的最低品格数*@paramfretEnd遍历的最高品格数*@parampreResult上一根弦确定的音的结果*@parampositionSave保存该轮递归的结果*/calc(stringIndex,toneIndex,fretStart,fretEnd,preResult,positionSave){lettoneArray=this.toneMap[stringIndex];letresult=false;//从和弦音的数组里逐个选出音进行试探(this.chordTone在后面提到的函数中赋值)for(leti=0;ithis.chordTone.length;i++){//相邻的上一根弦已使用的音不做本次计算if(i!==toneIndex){letresultNext=false;lettoneKey=this.chordTone[i];//在品格范围内查找当前音的位置letfret=this.findFret(toneKey,toneArray,fretStart,fretEnd);//品格范围内存在该音if(fret.length0){//记录该音的位置,几弦几品与音的数字描述letresultNow={string:stringIndex,fret:fret[0],key:toneKey}//在本次记录上保存上一根弦的结果,方便回溯resultNow.pre=preResult?preResult:null;//保存本次结果positionSave.push(resultNow);//设置该弦上的结果标记resultNext=true;//没有遍历完所有6根弦,则继续往下一根弦计算,附带上本次的结果记录if(stringIndexthis.initialTone.length){letnextStringIndex=stringIndex+1;//该弦上的结果的有效标记,取决上它后面的弦的结果均有效resultNext=resultNextthis.calc(nextStringIndex,i,fretStart,fretEnd,resultNow,positionSave);}else{//所有弦均遍历成功,代表递归结果有效resultNext=true;}//在该弦的计算结果无效,吐出之前保存的该弦结果if(!resultNext){positionSave.pop();}}else{//品格范围内不存在该音resultNext=false;}//任意一个和弦里的音,能在该弦取得有效结果,则该弦上的结果有效result=result||resultNext;}};returnresult;}

使用此递归方法,用1、3、5为和弦组成音做输入,会得到类似下面这样的结果:

编程,居然还可以用来学吉他!

递归在执行的时候,在每个节点上可能产生多个分支节点层层往下深入,以上的打印其实就是列出了每个节点的数据。而我们需要的是将这个递归结果拆分为不同指法结果的数组,就像下面这样:

编程,居然还可以用来学吉他!

为此添加一个filter函数:

//和弦指法过滤器filter(positionSave){//从6弦开始回溯记录的和弦指法结果,拆解出所有指法组合letallResult=positionSave.filter((item)={returnitem.string===this.initialTone.length}).map((item)={letresultItem=[{string:item.string,fret:item.fret,key:item.key}];while(item.pre){item=item.pre;resultItem.unshift({string:item.string,fret:item.fret,key:item.key});}returnresultItem;});if(allResult.length0){//依次调用各个过滤器returnthis.integrityFilter(this.fingerFilter(this.rootToneFilter(allResult)));}else{return[];}}

可以看到回溯计算出理想的结果形式后,末尾还调用了多个过滤器,因为代码计算出的符合组成音的所有指法组合,可能并不符合真实的按弦情况,需要进行多重的过滤。

4. 指法过滤

  • 根音条件过滤

例如以1、3、5作为和弦音,根音为1,而初步得到的结果可能如下:

编程,居然还可以用来学吉他!

而一个和弦在吉他上弹奏时,根音应该为所有发声的音中最低的音,上图中最低的音要么位于是6弦0品的3,要么是位于6弦3品的5,不符合要求,而5弦3品刚好是该和弦根音,故应该禁用第6弦(这里的禁用是将该弦的按弦品位fret标记为null)

//根音条件过滤rootToneFilter(preResult){letnextResult=newSet();preResult.forEach((item)={//允许发声的弦的总数,初始为6letrealStringLength=6;//从低音弦到高音弦遍历,不符合根音条件则禁止其发声for(vari=item.length-1;i=0;i--){if(item[i].key!==this.rootTone){item[i].fret=null;item[i].key=null;realStringLength--;}else{break;}}if(realStringLength=4){//去重复nextResult.add(JSON.stringify(item));}});//去重后的Set解析成对应数组返回return[...nextResult].map(item=JSON.parse(item));}

  • 按弦手指数量过滤

左手按弦的时候,一般最多只能用上4个手指(大拇指极少用到),而用递归方法算出的结果,可能包含了各种奇奇怪怪的按法,比如下面这个:

编程,居然还可以用来学吉他!

看上去包含了和弦的所有组成音,但是就算经过上一轮的过滤禁用了第6弦,每个非0的品位都需要用手指去按,这样算下来也需要5个手指,故类似这样的结果都应该二次过滤掉:

//按弦手指数量过滤fingerFilter(preResult){returnpreResult.filter((chordItem)={//按弦的最小品位letminFret=Math.min.apply(null,chordItem.map(item=item.fret).filter(fret=(fret!=null)));//记录需要的手指数量letfingerNum=minFret0?1:0;chordItem.forEach((item)={if(item.fret!=nullitem.fretminFret){fingerNum++;}});returnfingerNum=4;});}

  • 和弦组成音完整性过滤

递归计算所有可能的指法组合时,虽然保证了相邻两个音不重复,但不保证所有的和弦组成音都被使用了,而且在前一轮根音过滤时,可能禁用了部分弦的发声,这可能导致丢掉了其中唯一一个组成音,所以最后还需进行一轮完整性过滤,剔除残次品:

//和弦组成音完整性过滤integrityFilter(preResult){returnpreResult.filter((chordItem)={letkeyCount=[...newSet(chordItem.map(item=item.key).filter(key=key!=null))].length;returnkeyCount===this.chordTone.length;});}

5. 指法计算入口

由这里输入和弦的组成音,计算这些音所有可能出现的品格位置,然后从低到高,依次计算4或5个品格范围内的和弦指法,经整合过滤后得到该和弦所有的位置的正确指法。

//和弦指法计算入口chord(){letchordTone;if(is(arguments[0])('Array')){chordTone=arguments[0];}else{chordTone=Array.prototype.slice.apply(arguments).map((item)={lettone=newTone(item.toString());returntone.flat+tone.sharp+tone.key;});}//和弦组成音this.chordTone=chordTone;//根音this.rootTone=chordTone[0];this.chordResult=[];letfretArray=[];//查找和弦里的音可能存在的品格位置,保存至fretArraychordTone.forEach((item)={for(leti=1;ithis.toneMap.length;i++){fretArray=fretArray.concat(this.findFret(item,this.toneMap[i]));}});fretArray=[...newSet(fretArray)];//品格位置从小到大排序fretArray.sort((a,b)={returna-b;});//从低把位到高把位,计算范围内的所有该和弦指法for(leti=0;ifretArray.length;i++){letfretStart=fretArray[i];//在不需要使用大横按时,即在最低的把位计算时,可把计算的品格范围扩大一格letfretEnd=fretStart0?(fretStart+4):(fretStart+5);//最高范围不能超过吉他的最高品格数if(fretEndJSON.stringify(item)))].map(item=JSON.parse(item));returnresult;}

运行示例:

编程,居然还可以用来学吉他!

四、和弦指法结果可视化

特意挑选了svg作图,因为之前不会,借此机会学习了一下。

一个较为完整的和弦指法图,svg的代码示例如下(把这个扔到自己的html里打开也能直观看到结果):

CCEGCE1

显示效果如下:

编程,居然还可以用来学吉他!

当然了,得设计出一套可以画任意svg指法图的方案。

简单来说,就是将指法图拆分为多个子元素,有的画网格,有的画按弦位置,有的画空弦符号,诸如此类,然后根据传入的指法结果,动态创建这些子元素加入svg即可;但需特别考虑各个元素可能会动态改变的位置,以及对于大横按的绘图处理。

此处代码及svg公共样式详见原文。

哐当当一个运行示例:

编程,居然还可以用来学吉他!

当然,我怎会止步于此。

基于以上已经实现的代码,我又折腾出了一个网页工具,在数字上左右拖动来改变和弦的组成音,从而时时计算和弦指法图:

编程,居然还可以用来学吉他!

编程,居然还可以用来学吉他!

如果你不按套路出牌,给了间隔古怪的组成音,可能会这样(因为算不出完整的和弦名字了,就用省略号代替了):

编程,居然还可以用来学吉他!

当然,如果你乱拖一通,大多数情况会是这样:

编程,居然还可以用来学吉他!


上过香的尾

一边搜着基础乐理,一边填补着漫无边际的知识空白,可算是把这个东西弄出来了,涉及的还只是音乐基础的冰山一角,比如还有许多更高级的更多音组成的和弦、以及更加稀奇古怪的和弦名字,能力有限,这里就先不纳入考虑范畴了。

不得不说,我明明是来写代码的,却不知不觉给自己上起了音乐小课。

有些做事的动力就是这么奇妙。

若看官还觉得饶有意思,便胜却人间无数。


分享到:


相關文章: