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公共樣式詳見原文。

哐噹噹一個運行示例:

編程,居然還可以用來學吉他!

當然,我怎會止步於此。

基於以上已經實現的代碼,我又折騰出了一個網頁工具,在數字上左右拖動來改變和絃的組成音,從而時時計算和絃指法圖:

編程,居然還可以用來學吉他!

編程,居然還可以用來學吉他!

如果你不按套路出牌,給了間隔古怪的組成音,可能會這樣(因為算不出完整的和絃名字了,就用省略號代替了):

編程,居然還可以用來學吉他!

當然,如果你亂拖一通,大多數情況會是這樣:

編程,居然還可以用來學吉他!


上過香的尾

一邊搜著基礎樂理,一邊填補著漫無邊際的知識空白,可算是把這個東西弄出來了,涉及的還只是音樂基礎的冰山一角,比如還有許多更高級的更多音組成的和絃、以及更加稀奇古怪的和絃名字,能力有限,這裡就先不納入考慮範疇了。

不得不說,我明明是來寫代碼的,卻不知不覺給自己上起了音樂小課。

有些做事的動力就是這麼奇妙。

若看官還覺得饒有意思,便勝卻人間無數。


分享到:


相關文章: