吉他和絃推導演算法

youngdro發表於2018-07-03

開過光的序

當一個民謠小哥抱著吉他哼唱著《情非得已》時,他右手掃著音孔處的琴絃,左手變換著按著琴頸處的琴絃,一段簡單的彈唱便看起來有模有樣。在不看臉不看唱功的情況下,是什麼原理才賦予這位小哥如此風騷的魅力呢?

這就是吉他伴奏。

而他只是一個吉他初學者,還沒辦法給歌曲編配伴奏,只好從網上找來吉他譜,按照裡面的標識來進行彈奏。他找到了下面這樣的譜子:

吉他和絃推導演算法

這是一個典型的吉他彈唱譜該有的樣子,它可以被分成四個部分:

  • 和絃指法:用於標記該小節內的和絃名以及對應的按弦指法。
  • 六線譜:它是專門屬於吉他的譜子,六條橫線至上而下分別對應吉他的一弦到六絃,橫線上新增各種符號來標記右手的彈奏方式。
  • 簡譜:這裡是數字簡譜,配以各種符號來描述歌曲的旋律與節奏。
  • 歌詞:嗯,就是歌詞。

對於初學者,吉他入門的坎兒在於左手的指法,當時我記下了大多數和絃的指法圖,左手指尖的磨出的繭也是起了褪,褪了起,身為樂理渣的我終有一天疑惑了,問號三連:

1. 這個和絃為什麼叫這個名字?

2. 這個和絃為什麼是這個指法?

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

本文將基於基本的樂理知識,用程式碼推導計算出以上問題的答案,並將其結果視覺化。


一、從一個單音說起

心虛的宣告:外行人基於自己的理解強行解釋樂理,望專業人士輕噴

聲音因物體振動而產生,每一個不同頻率(即不同音高)的聲響都可以稱之為一個單音,但人耳的辨音能力有限,音樂體系裡將一段音程中相鄰的單音的最小間隔稱為半音(這樣劃分既在人耳的辨音範圍內,又符合音程的迴圈);

相隔半音的兩個音的頻率比值為2的12次方根

為什麼是這個值,這就得提到十二平均律

音樂界老前輩經過大量的聽力實踐後,發現例如do高音do這個音程作為一個迴圈聽起來最和諧,並且這高音dodo的頻率比率剛好是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. 構建單音類

// 檢測資料型別的公用方法
function is(data) {
    return function(type) {
        return Object.prototype.toString.call(data) === `[object ${type}]`;
    }
}

// 單音類,用於音的對映查詢與音高的改變,同時可標記記錄其在吉他上的位置
class Tone {
    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('#') ? '#' : '';
        let octave_arr = toneString.split(this.key);
        let octave_flat = octave_arr[0].toString().match(/\./g);
        let octave_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) {
        return this.keyMap.findIndex((item) => {
            if (is(item)('Array')) {
                return item.includes(keyString);
            } else if (item === keyString) {
                return true;
            } else {
                return false;
            }
        });
    }
    // 音高增減,num為增或減的半音數量
    step(num) {
        let keyString = this.flat + this.sharp + this.key;
        let len = this.keyMap.length;
        let index = this.findKeyIndex(keyString);
        if (index > -1) {
            num = +num;
            // 計算改變音高後的音在音程上的位置
            let nextIndex = parseInt(index + num, 0);
            let octave = this.octave;
            if (nextIndex >= len) {
                let index_gap = nextIndex - len;
                octave += Math.floor(index_gap / len) + 1;
                nextIndex = index_gap % len;
            } else if (nextIndex < 0) {
                let index_gap = nextIndex;
                octave += Math.floor(index_gap / len);
                nextIndex = index_gap % len + len;
            }
            let nextKey = this.keyMap[nextIndex];
            // 計算並新增高低八度的記號
            let octaveString = new Array(Math.abs(octave)).fill('.').join('');
            let toneString = '';
            if (!is(nextKey)('Array')) {
                toneString = (octave < 0 ? octaveString : '') + nextKey + (octave > 0 ? octaveString : '');
                return new this.constructor(toneString, this.position.string, this.position.fret + num);
            } else {
                // 可能得到兩個音高一樣但標記方式不一樣的音
                return nextKey.map((key) => {
                    return new this.constructor((octave < 0 ? octaveString : '') + key + (octave > 0 ? octaveString : ''), this.position.string, this.position.fret + num);
                });
            }
        } else {
            return null;
        }
    }
}
複製程式碼

有了這個單音類後,後續可以借用它來方便地對比兩個音之間的跨度,並且可以通過構建吉他每根弦的初始音,通過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/b22#2/b3這3個音,共4個半音的跨度;

3(mi)5(sol)中間還夾了4#4/b5這2個音,共3個半音的跨度;

那麼像這樣組成的和絃就成為大三和絃

2. 常見和絃標記規則

和絃型別 組成 標記
大三和絃 大三度 + 小三度
小三和絃 小三度 + 大三度 m
增三和絃 大三度 + 大三度 aug
減三和絃 小三度 + 小三度 dim
大小七和絃(屬七和絃) 大三和絃 + 小三度 7Mm7
大大七和絃(大七和絃) 大三和絃 + 大三度 maj7M7
小小七和絃(小七和絃) 小三和絃 + 小三度 m7mm7
小大七和絃 小三和絃 + 大三度 mM7
減七和絃 減三和絃 + 小三度 dim7
半減七和絃 減三和絃 + 大三度 m7-5
增屬七和絃 增三和絃 + 減三度 7#5M7+5
增大七和絃 增三和絃 + 小三度 aug7Maj7#5

加音和絃指定和絃根音相對複雜些,暫不討論。

3. 和絃根音

和絃組成音中的第一個音為和絃的根音,也叫基礎音,可以根據當前的調式和某和絃的根音來判斷該和絃的初始名稱,例如在C調下,根音和絃名的對照關係如下:

1 2 3 4 5 6 7
C D E F G A B

通俗點說相當於,在某調下,一個和絃的根音為該調的1(do)時,那它就叫某和絃(額外標記根據音之間的三度關係再新增),例如:

C調下

根音為1(do)構成的和絃名為C

根音為2(re)構成的和絃名為D

D調下

根音為1(do)構成的和絃名為D

根音為1(do)構成的和絃名為E

B調下

根音為1(do)構成的和絃名為B

根音為2(do)構成的和絃名為C

4. 和絃完整名稱計算

基於以上的樂理規則,可以實現如下推導和絃名的類:

// 和絃名稱推導
class ChordName {
    constructor(chordTone) {
        // 例項化一個單音類做工具,用來計算音與各種標記的對映關係
        this.toneUtil = new Tone();
    }
    // 獲取兩個音的間隔跨度
    getToneSpace(tonePre, toneNext) {
        let toneSpace = this.toneUtil.findKeyIndex(toneNext) - this.toneUtil.findKeyIndex(tonePre);    
        return toneSpace = toneSpace < 0 ? toneSpace + 12 : toneSpace;
    }
    // 大三度
    isMajorThird(tonePre, toneNext) {
        return this.getToneSpace(tonePre, toneNext) === 4;
    }
    // 小三度
    isMinorThird(tonePre, toneNext) {
        return this.getToneSpace(tonePre, toneNext) === 3;
    }
    // 增三度
    isMajorMajorThird(tonePre, toneNext) {
        return this.getToneSpace(tonePre, toneNext) === 5;
    }
    // 減三度
    isMinorMinorThird(tonePre, toneNext) {
        return this.getToneSpace(tonePre, toneNext) === 2;
    }
    // 大三和絃
    isMajorChord(chordTone) {
        return this.isMajorThird(chordTone[0], chordTone[1]) && this.isMinorThird(chordTone[1], chordTone[2]);
    }
    // 小三和絃 m
    isMinorChord(chordTone) {
        return this.isMinorThird(chordTone[0], chordTone[1]) && this.isMajorThird(chordTone[1], chordTone[2]);
    }
    // 增三和絃 aug
    isAugmentedChord(chordTone) {
        return this.isMajorThird(chordTone[0], chordTone[1]) && this.isMajorThird(chordTone[1], chordTone[2]);
    }
    // 減三和絃 dim
    isDiminishedChord(chordTone) {
        return this.isMinorThird(chordTone[0], chordTone[1]) && this.isMinorThird(chordTone[1], chordTone[2]);
    }
    // 掛四和絃
    isSus4(chordTone) {
        return this.isMajorMajorThird(chordTone[0], chordTone[1]) && this.isMinorMinorThird(chordTone[1], chordTone[2]);
    }
    // 大小七和絃/屬七和絃 7 / Mm7
    isMajorMinorSeventhChord(chordTone) {
        if (chordTone.length < 4) return false;
        return this.isMajorChord(chordTone) && this.isMinorThird(chordTone[2], chordTone[3]);
    }
    // 小大七和絃 mM7
    isMinorMajorSeventhChord(chordTone) {
        if (chordTone.length < 4) return false;
        return this.isMinorChord(chordTone) && this.isMajorThird(chordTone[2], chordTone[3]);
    }
    // 大七和絃 maj7 / M7
    isMajorMajorSeventhChord(chordTone) {
        if (chordTone.length < 4) return false;
        return this.isMajorChord(chordTone) && this.isMajorThird(chordTone[2], chordTone[3]);
    }
    // 小七和絃 m7 / mm7
    isMinorMinorSeventhChord(chordTone) {
        if (chordTone.length < 4) return false;
        return this.isMinorChord(chordTone) && this.isMinorThird(chordTone[2], chordTone[3]);
    }
    // 減七和絃 dim7
    isDiminishedSeventhChord(chordTone) {
        if (chordTone.length < 4) return false;
        return this.isDiminishedChord(chordTone) && this.isMinorThird(chordTone[2], chordTone[3]);
    }
    // 半減七和絃 m7-5
    isHalfDiminishedSeventhChord(chordTone) {
        if (chordTone.length < 4) return false;
        return this.isDiminishedChord(chordTone) && this.isMajorThird(chordTone[2], chordTone[3]);
    }
    // 增屬七和絃  7#5 / M7+5
    isHalfAugmentedSeventhChord(chordTone) {
        if (chordTone.length < 4) return false;
        return this.isAugmentedChord(chordTone) && this.isMinorMinorThird(chordTone[2], chordTone[3]);
    }
    // 增大七和絃 aug7 / Maj7#5
    isAugmentedSeventhChord(chordTone) {
        if (chordTone.length < 4) return false;
        return this.isAugmentedChord(chordTone) && this.isMinorThird(chordTone[2], chordTone[3]);
    }
    // 獲取音對應的根音和絃名
    getKeyName(key) {
        let keyName = this.toneUtil.intervalMap[this.toneUtil.findKeyIndex(key)];
        if (is(keyName)('Array')) {
            keyName = /b/.test(key) ? keyName[1] : keyName[0];
        };
        return keyName;
    }
    // 計算和絃名
    getChordName(chordTone) {
        let rootKey = chordTone[0];
        // 和絃的字母名
        let chordRootName = this.getKeyName(rootKey);
        // 和絃字母后面的具體修飾名
        let suffix = '...';
        let suffixArr = [];
        // 三音和絃的遍歷方法及對應修飾名
        let chord3SuffixMap = [{
            fn: this.isMajorChord,
            suffix: ''
        }, {
            fn: this.isMinorChord,
            suffix: 'm'
        }, {
            fn: this.isAugmentedChord,
            suffix: 'aug'
        }, {
            fn: this.isDiminishedChord,
            suffix: 'dim'
        }, {
            fn: this.isSus4,
            suffix: 'sus4'
        }];
        // 四音和絃的遍歷方法及對應修飾名
        let chord4SuffixMap = [{
            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) => {
                return item.fn.bind(this, chordTone)();
            });
            suffix = suffixArr.length > 0 ? suffixArr[0].suffix : suffix;
        } else {
        // 四音和絃
            suffixArr = chord4SuffixMap.filter((item) => {
                return item.fn.bind(this, chordTone)();
            });
            suffix = suffixArr.length > 0 ? suffixArr[0].suffix : suffix;
        }
        // 拼接起來得到完整的和絃名
        return chordRootName + suffix;
    }
}
複製程式碼

執行示例:

吉他和絃推導演算法


三、和絃指法推導

1. 指法圖

一個完整的吉他和絃指法圖的例子如下,右邊對照為真實的吉他:

吉他和絃推導演算法
說明下幾個名詞的意思:

品位:真實的吉他琴頸上被劃分成了很多格子,當手指按在不同的格子上時,改變了對應琴絃振動的弦長,那麼它的發音高低也會跟著改變,這些按住可以改變音高的格子就被稱作品位,或品格(不得不說這“品”字取得真好);

品位標記:在指法圖的左側標記了一個數字,表示圖上該行的品位實際位於吉他上的品位是多少(所謂的相對座標系與絕對座標系);

空絃音:即第0品,左手不用按標記對應的弦,右手直接撥它;

和絃外音:當某根弦的空絃音以及在它在該指法圖範圍內能產生的音都不屬於該和絃的組成音時,那麼這根弦應該禁止彈奏,故在該弦上面標記一個“叉號”

按弦手指標記:用黑色的圓點標記手指按在各弦上的位置,最完整的指法圖還會在上面加上數字1234,分別代表食指中指無名指小拇指,相當於哪根手指該放哪兒都告訴清楚了。

2. 吉他弦上音的分佈

我從網上摳來了這張帶著歷史氣息的彩圖:

吉他和絃推導演算法
可以觀察到,同樣一個音,在吉他弦上的位置可以有許多個;而簡單的和絃的組成音也就三四個,所以要想一下子從這些縱橫的格子裡尋出某個和絃所有可能的指法,同時還要考慮實際指法的各種約束:

比如你左手能用上的只有不超過5根手指頭而弦有6根,但食指是可以使用大橫按按多根弦的,但大橫按只能按在該指法的最低品位上;還得考慮指法按弦後是包括了和絃裡所有的音,同時相鄰兩弦的音不能一樣...

諸如此類,想要一下子心算出來所有可能的結果,怕是為難我胖虎了。

不過這個很適合用遞迴演算法解決。

3. 指法推導

為此專門構建一個類,在初始化的時候使用之前寫的單音類,算出吉他弦上所有位置的音。之後就可以通過this.toneMap[tring][fret]的形式直接獲得該位置的音,例如this.toneMap[1][3]獲取1弦3品的音。

// 吉他和絃指法推導類
class GuitarChord {
    constructor() {
        // 暫定的吉他的最大品格數
        this.fretLength = 15;
        // 構建1到6弦的初始音
        this.initialTone = [
            new Tone('3.', 1, 0),
            new Tone('7', 2, 0),
            new Tone('5', 3, 0),
            new Tone('2', 4, 0),
            new Tone('.6', 5, 0),
            new Tone('.3', 6, 0)
        ];
        // 用於吉他上所有位置對應的音
        this.toneMap = [];
        // 從1到6弦,從品位數的低到高,依次計算每個位置的音
        for (let string = 1; string <= this.initialTone.length; string++) {
            this.toneMap[string] = [];
            for (let fret = 0; fret <= this.fretLength; fret++) {
                this.toneMap[string].push(this.initialTone[string - 1].step(fret));
            }
        }
    }
}
複製程式碼

給它加上一個公用的單音位置搜尋方法:

// 在指定的品格數範圍內,查詢某個音在某根弦的音域下所有的品格位置
/*
* @param key 搜尋的音(字串形式)
* @param toneArray 音域陣列,即某根弦上所有單音類按順序組成的陣列
* @param fretStart 搜尋的最低品格數
* @param fretEnd 搜尋的最高品格數
*/
findFret(key, toneArray, fretStart, fretEnd) {
    key = key.replace(/\./g, '');
    let fretArray = [];
    fretStart = fretStart ? fretStart : 0;
    fretEnd = fretEnd ? (fretEnd + 1) : toneArray.length;
    for (let i = fretStart; i < fretEnd; i++) {
        if (is(toneArray[i])('Array')) {
            let toneStringArray = toneArray[i].map((item) => {
                return item.toneNormal;
            });
            if (toneStringArray.includes(key)) {
                fretArray.push(i);
            }
        } else {
            if (toneArray[i].toneString.replace(/\./g, '') === key) {
                fretArray.push(i);
            }
        }
    }
    return fretArray;
}
複製程式碼

接下來是核心的迴圈遞迴演算法,先構思下大致的遞迴的流程:

(1)指定從1弦開始,啟動遞迴。(遞迴入口)

(2)指定了某弦後,迴圈遍歷和絃的組成音,計算是否有音落在該弦指定的品位範圍內,如果沒有,返回false;如果有,轉步驟(3)。

(3)先儲存該音與它的按弦位置,當前位置最終有效取決於,當且僅當在它後面的所有弦也是能找到按弦位置的有效解,如果該弦是第6弦,返回true,遞迴結束(遞迴出口),否則轉步驟(4);

(4)當前結果最終的有效性 = 當前臨時結果有效性(true) && 下一根弦是否存在有效解(此時已轉至步驟(3)) 。若當前結果最終有效,返回true;若無效,回退pop出之前在該弦儲存的結果。

最後實現還需考慮相鄰兩絃音不能相同,另外為了便於回溯整體結果,在單次的結果儲存時,新增了指向上一次結果的指標pre

// 遞迴遍歷範圍內的指定和絃的所有位置組合
/*
 * @param stringIndex 當前遍歷到的弦的序號
 * @param toneIndex 上一根弦使用的音的序號(用於相鄰的兩根弦的音不重複)
 * @param fretStart 遍歷的最低品格數
 * @param fretEnd 遍歷的最高品格數
 * @param preResult 上一根弦確定的音的結果
 * @param positionSave 儲存該輪遞迴的結果
 */
calc(stringIndex, toneIndex, fretStart, fretEnd, preResult, positionSave) {
    let toneArray = this.toneMap[stringIndex];
    let result = false;
    // 從和絃音的陣列裡逐個選出音進行試探(this.chordTone在後面提到的函式中賦值)
    for (let i = 0; i < this.chordTone.length; i++) {
        // 相鄰的上一根弦已使用的音不做本次計算
        if (i !== toneIndex) {
            let resultNext = false;
            let toneKey = this.chordTone[i];
            // 在品格範圍內查詢當前音的位置
            let fret = this.findFret(toneKey, toneArray, fretStart, fretEnd);
            // 品格範圍記憶體在該音
            if (fret.length > 0) {
                // 記錄該音的位置,幾弦幾品與音的數字描述
                let resultNow = {
                    string: stringIndex,
                    fret: fret[0],
                    key: toneKey
                }
                // 在本次記錄上儲存上一根弦的結果,方便回溯
                resultNow.pre = preResult ? preResult : null;
                // 儲存本次結果
                positionSave.push(resultNow);
                // 設定該弦上的結果標記
                resultNext = true;
                // 沒有遍歷完所有6根弦,則繼續往下一根弦計算,附帶上本次的結果記錄
                if (stringIndex < this.initialTone.length) {
                    let nextStringIndex = stringIndex + 1;
                    // 該弦上的結果的有效標記,取決上它後面的弦的結果均有效
                    resultNext = resultNext && this.calc(nextStringIndex, i, fretStart, fretEnd, resultNow, positionSave);
                } else {
                    // 所有弦均遍歷成功,代表遞迴結果有效
                    resultNext = true;
                }
                // 在該弦的計算結果無效,吐出之前儲存的該弦結果
                if (!resultNext) {
                    positionSave.pop();
                }
            } else {
                // 品格範圍內不存在該音
                resultNext = false;
            }
            // 任意一個和絃裡的音,能在該弦取得有效結果,則該弦上的結果有效
            result = result || resultNext;
        }
    };
    return result;
}
複製程式碼

使用此遞迴方法,用135為和絃組成音做輸入,會得到類似下面這樣的結果:

吉他和絃推導演算法
遞迴在執行的時候,在每個節點上可能產生多個分支節點層層往下深入,以上的列印其實就是列出了每個節點的資料。而我們需要的是將這個遞迴結果拆分為不同指法結果的陣列,就像下面這樣:
吉他和絃推導演算法
為此新增一個filter函式:

// 和絃指法過濾器
filter(positionSave) {
    // 從6弦開始回溯記錄的和絃指法結果,拆解出所有指法組合
    let allResult = positionSave.filter((item) => {
        return item.string === this.initialTone.length
    }).map((item) => {
        let resultItem = [{
            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
            });
        }
        return resultItem;
    });
    if (allResult.length > 0) {
        // 依次呼叫各個過濾器
        return this.integrityFilter(this.fingerFilter(this.rootToneFilter(allResult)));
    } else {
        return [];
    }
}
複製程式碼

可以看到回溯計算出理想的結果形式後,末尾還呼叫了多個過濾器,因為程式碼計算出的符合組成音的所有指法組合,可能並不符合真實的按弦情況,需要進行多重的過濾。

4. 指法過濾

  • 根音條件過濾

例如以135作為和絃音,根音為1,而初步得到的結果可能如下:

吉他和絃推導演算法
而一個和絃在吉他上彈奏時,根音應該為所有發聲的音中最低的音,上圖中最低的音要麼位於是6弦0品3,要麼是位於6弦3品5,不符合要求,而5弦3品剛好是該和絃根音,故應該禁用第6弦(這裡的禁用是將該弦的按弦品位fret標記為null

// 根音條件過濾
rootToneFilter(preResult) {
    let nextResult = new Set();
    preResult.forEach((item) => {
        // 允許發聲的弦的總數,初始為6
        let realStringLength = 6;
        // 從低音弦到高音弦遍歷,不符合根音條件則禁止其發聲
        for (var i = 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) {
    return preResult.filter((chordItem) => {
        // 按弦的最小品位
        let minFret = Math.min.apply(null, chordItem.map(item => item.fret).filter(fret => (fret != null)));
        // 記錄需要的手指數量
        let fingerNum = minFret > 0 ? 1 : 0;
        chordItem.forEach((item) => {
        if (item.fret != null && item.fret > minFret) {
            fingerNum++;
        }
        });
        return fingerNum <= 4;
    });
}
複製程式碼
  • 和絃組成音完整性過濾

遞迴計算所有可能的指法組合時,雖然保證了相鄰兩個音不重複,但不保證所有的和絃組成音都被使用了,而且在前一輪根音過濾時,可能禁用了部分弦的發聲,這可能導致丟掉了其中唯一一個組成音,所以最後還需進行一輪完整性過濾,剔除殘次品:

// 和絃組成音完整性過濾
integrityFilter(preResult) {
    return preResult.filter((chordItem) => {
        let keyCount = [...new Set(chordItem.map(item => item.key).filter(key => key != null))].length;
        return keyCount === this.chordTone.length;
    });
}
複製程式碼

5. 指法計算入口

由這裡輸入和絃的組成音,計算這些音所有可能出現的品格位置,然後從低到高,依次計算4或5個品格範圍內的和絃指法,經整合過濾後得到該和絃所有的位置的正確指法。

注意,這裡的輸入音是在C調的基礎下,故計算出的對應的和絃名和指法圖也是C調下的。

// 和絃指法計算入口
chord() {
    let chordTone;
    if (is(arguments[0])('Array')) {
        chordTone = arguments[0];
    } else {
        chordTone = Array.prototype.slice.apply(arguments).map((item) => {
            let tone = new Tone(item.toString());
                return tone.flat + tone.sharp + tone.key;
            });
    }
    // 和絃組成音
    this.chordTone = chordTone;
    // 根音
    this.rootTone = chordTone[0];
    this.chordResult = [];
    let fretArray = [];
    // 查詢和絃裡的音可能存在的品格位置,儲存至fretArray
    chordTone.forEach((item) => {
        for (let i = 1; i < this.toneMap.length; i++) {
            fretArray = fretArray.concat(this.findFret(item, this.toneMap[i]));
        }
    });
    fretArray = [...new Set(fretArray)];
    // 品格位置從小到大排序
    fretArray.sort((a, b) => {
        return a - b;
    });
    // 從低把位到高把位,計算範圍內的所有該和絃指法
    for (let i = 0; i < fretArray.length; i++) {
        let fretStart = fretArray[i];
        // 在不需要使用大橫按時,即在最低的把位計算時,可把計算的品格範圍擴大一格
        let fretEnd = fretStart > 0 ? (fretStart + 4) : (fretStart + 5);
        // 最高範圍不能超過吉他的最高品格數
        if (fretEnd <= this.fretLength) {
            let positionSave = [];
            // 從1弦開始啟動遞迴計算
            if (this.calc(1, null, fretStart, fretEnd, null, positionSave)) {
                // 單次結果過濾並儲存
                this.chordResult.push(...this.filter(positionSave));
            }
        }
    }
    // 結果去重
    let result = [...new Set(this.chordResult.map(item => JSON.stringify(item)))].map(item => JSON.parse(item));
    return result;
}
複製程式碼

執行示例:

吉他和絃推導演算法


三、和絃指法結果視覺化

特意挑選了svg作圖,因為之前不會,藉此機會學習了一下。

一個較為完整的和絃指法圖,svg的程式碼示例如下(把這個扔到自己的html裡開啟也能直觀看到結果):

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="svg" width="200" height="220" viewBox="0 0 150 150" preserveAspectRatio="xMidYMin meet">
    <defs>
        <g id="forbidden">
            <path d="M-5 -5 L5 5 M-5 5 L5 -5" stroke="#666" stroke-width="1" fill="none"/>
        </g>
        <g id="blank_circle">
            <circle cx="0" cy="0" r="6" stroke="#666" stroke-width="1" fill="none"/>
        </g>
        <g id="block_circle">
            <circle cx="0" cy="0" r="8" fill="#333"/>
        </g>
    </defs>
    <rect x="25" y=45 rx="5" ry="5" width="100" height="100" style="fill:none;stroke:#666;stroke-width:2"/>
    <path d="M25 65 L125 65 M25 85 L125 85 M25 105 L125 105 M25 125 L125 125 M45 45 L45 145 M65 45 L65 145 M85 45 L85 145 M105 45 L105 145 M25 40 L125 40" stroke="#666" stroke-width="2" fill="none"/>
    <use xlink:href="#forbidden" x="25" y="30" />
    <use xlink:href="#blank_circle" x="125" y="30" />
    <use xlink:href="#blank_circle" x="85" y="30" />
    <use xlink:href="#block_circle" x="105" y="55" />
    <use xlink:href="#block_circle" x="65" y="75" />
    <use xlink:href="#block_circle" x="45" y="95" />
    <text x="67" y="20" fill="#333" font-size="20" font-weight="700">C</text>
    <text x="41.5" y="160" fill="#333" font-size="10" font-weight="700">C</text>
    <text x="61.5" y="160" fill="#333" font-size="10" font-weight="700">E</text>
    <text x="81.5" y="160" fill="#333" font-size="10" font-weight="700">G</text>
    <text x="101.5" y="160" fill="#333" font-size="10" font-weight="700">C</text>
    <text x="121.5" y="160" fill="#333" font-size="10" font-weight="700">E</text>
    <text x="8" y="60" font-size="14" font-weight="700" fill="#333">1</text>
</svg>
複製程式碼

顯示效果如下:

吉他和絃推導演算法
當然了,得設計出一套可以畫任意svg指法圖的方案。

簡單來說,就是將指法圖拆分為多個子元素,有的畫網格,有的畫按弦位置,有的畫空弦符號,諸如此類,然後根據傳入的指法結果,動態建立這些子元素加入svg即可;但需特別考慮各個元素可能會動態改變的位置,以及對於大橫按的繪圖處理。

這裡程式碼我一摞全擺出來了,帶了較為詳盡的註釋,就不細講了(打字打累了...)

// 和絃svg繪圖
class ChordSvg {
    constructor() {
        this.SVG_NS = "http://www.w3.org/2000/svg";
        this.XLINK_NS = "http://www.w3.org/1999/xlink";
        this.ATTR_MAP = {
            "className": "class",
            "svgHref": "href"
        };
        this.NS_MAP = {
            "svgHref": this.XLINK_NS
        };
        this.initChordSvg();
        this.minFret = 0;
    }
    // 建立svg相關元素
    createSVG(tag, attributes) {
        let elem = document.createElementNS(this.SVG_NS, tag);
        for (let attribute in attributes) {
            let name = (attribute in this.ATTR_MAP ? this.ATTR_MAP[attribute] : attribute);
            let value = attributes[attribute];
            if (attribute in this.NS_MAP) {
                elem.setAttributeNS(this.NS_MAP[attribute], name, value);
            } else {
                elem.setAttribute(name, value);
            }
        }
        return elem;
    }
    // 建立use標籤
    createUse(href, x, y) {
        return this.createSVG('use', {
            svgHref: href,
            x: x,
            y: y
        });
    }
    // 設定禁止彈奏的叉號位置,位於幾弦
    setForbidden(svg, string = 6) {
        svg.appendChild(this.createUse('#forbidden', 25 + 20 * (6 - string), 30));
    }
    // 設定空弦彈奏的空心圈位置,位於幾弦
    setOpen(svg, string = 6) {
        svg.appendChild(this.createUse('#blank_circle', 25 + 20 * (6 - string), 30));
    }
    // 設定指法按弦位置,幾弦幾品
    setFinger(svg, string = 6, fret = 0) {
        if (+fret > 0 && +fret <= 5) {
            svg.appendChild(this.createUse('#block_circle', 25 + 20 * (6 - string), 35 + 20 * fret));
        }
    }
    // 設定大橫按位置
    setBarre(svg, stringTo, fret, barreFret) {
        if (fret > 0 && fret <= 5) {
            svg.appendChild(this.createSVG('rect', {
                className: 'chord-barre',
                width: stringTo * 20,
                x: 15 + 20 * (6 - stringTo),
                y: 27 + 20 * fret,
                rx: 8,
                ry: 8
            }));
        }
    }
    // 設定把位偏移的數字提示
    setFretOffset(svg, fret, fretOffset, isBarreCover) {
        if (fret > 0) {
            let text = this.createSVG('text', {
                className: 'chord-barre-fret',
                x: isBarreCover ? 1 : 8,
                y: 40 + fret * 20
            });
            text.innerHTML = fretOffset;
            svg.appendChild(text);
        }
    }
    // 設定每根弦在按住和絃後的發音名
    setStringKey(svg, string, keyName) {
        let xFixed = keyName.length === 2 ? -4 : 0;
        let text = this.createSVG('text', {
            className: 'chord-string-key',
            x: 21.5 + 20 * (6 - string) + xFixed,
            y: 160
        });
        text.innerHTML = keyName;
        svg.appendChild(text);
    }
    // 設定和絃名稱
    setChordName(svg, name = '') {
        let xFixed = /\.\.\./.test(name) ? 10 : 0;
        let text = this.createSVG('text', {
            className: 'chord-name',
            x: 75 - name.toString().length * 7 + xFixed,
            y: 20
        });
        text.innerHTML = name;
        svg.appendChild(text);
    }
    // 初始化svg
    initChordSvg() {
        // svg元素
        this.svg = this.createSVG('svg', {
            className: 'chord-svg',
            viewBox: '0 0 150 150',
            preserveAspectRatio: 'xMidYMin meet'
        });
        // 和絃圖方塊
        this.chordRect = this.createSVG('rect', {
            className: 'chord-rect',
            x: 25,
            y: 45,
            rx: 5,
            ry: 5
        });
        // 和絃網格,代表弦和品
        this.chordGird = this.createSVG('path', {
            className: 'chord-gird',
            d: 'M25 65 L125 65 M25 85 L125 85 M25 105 L125 105 M25 125 L125 125 M45 45 L45 145 M65 45 L65 145 M85 45 L85 145 M105 45 L105 145 M25 40 L125 40'
        });
        // 用於放置可複用的svg元素
        this.defs = this.createSVG('defs');
        // 禁止按弦的叉號標誌
        this.g_forbidden = this.createSVG('g', {
            id: 'forbidden'
        });
        this.g_forbidden.appendChild(this.createSVG('path', {
            className: 'chord-forbidden',
            d: 'M-5 -5 L5 5 M-5 5 L5 -5'
        }));
        // 空弦彈奏的空心圈標誌
        this.g_blank_circle = this.createSVG('g', {
            id: 'blank_circle',
        });
        this.g_blank_circle.appendChild(this.createSVG('circle', {
            className: 'chord-blank-circle',
            cx: 0,
            cy: 0,
            r: 6
        }));
        // 表示按弦位置的實心圈標誌
        this.g_block_circle = this.createSVG('g', {
            id: 'block_circle'
        });
        this.g_block_circle.appendChild(this.createSVG('circle', {
            className: 'chord-block-circle',
            cx: 0,
            cy: 0,
            r: 8
        }));
        // 可複用元素加入
        this.defs.appendChild(this.g_forbidden);
        this.defs.appendChild(this.g_blank_circle);
        this.defs.appendChild(this.g_block_circle);
        // svg子元素加入
        this.svg.appendChild(this.chordRect);
        this.svg.appendChild(this.chordGird);
        this.svg.appendChild(this.defs);
    }
    // 繪製和絃svg圖案
    /*
    * @param chordTone 和絃組成音陣列
    * @param chord 和絃指法結果
    * @param target svg指法圖dom容器
    */
    drawChord(chordTone, chord, target) {
        let svg = this.svg.cloneNode(true);
        let fretArr = chord.map(item => item.fret).filter(fret => (fret != null));
        // 和絃指法中出現的最高品格位置
        let maxFret = Math.max.apply(null, fretArr);
        // 和絃指法中出現的最低品位位置
        let minFret = Math.min.apply(null, fretArr);
        // svg指法圖案的起始品格位置相對於吉他上0品位置的偏移量
        let fretOffset = maxFret <= 5 ? 0 : minFret;
        // 記錄指法最低品位可能需要大橫按的按弦數
        let barreCount = 0;
        // 大橫按初始只橫跨1弦到1弦(相當於沒橫按)
        let barreStringTo = 1;
        // 例項化用於計算和絃名稱的類
        let chordName = new ChordName();
        // 遍歷和絃指法陣列
        chord.forEach((item) => {
            if (item.fret == null) {
                // 某根弦沒標記品格位置時禁止該弦彈奏
                this.setForbidden(svg, item.string);
            } else if (item.fret === 0) {
                // 某根弦沒標記的品格位置為0品時標記空弦彈奏
                this.setOpen(svg, item.string);
            } else {
                // 剩下的指法繪製其對應的按法位置
                this.setFinger(svg, item.string, fretOffset > 0 ? item.fret - fretOffset + 1 : item.fret);
            }
            // 當按在該和絃的最低品格位置的指法反覆出現時
            if (item.fret === minFret) {
                // 計算大橫按的跨度
                barreStringTo = item.string > barreStringTo ? item.string : barreStringTo;
                // 計算大橫按實際按弦的數量
                barreCount++;
            }
            // 在允許彈奏的弦的下方標記其對應的音名
            if (item.fret != null) {
                this.setStringKey(svg, item.string, chordName.getKeyName(item.key));
            }
        });
        // 將真實的按弦品格位置轉換為相對於svg圖案上的品格位置
        let relativeFret = fretOffset > 0 ? minFret - fretOffset + 1 : minFret;
        if (barreCount > 1) {
            // 橫按數大於1才需要使用大橫按
            this.setBarre(svg, barreStringTo, relativeFret, minFret);
        }
        // 在圖案左側繪製品格位置偏移標記
        this.setFretOffset(svg, relativeFret, minFret, barreStringTo === 6);
        // 在圖案上側繪製和絃名稱
        this.setChordName(svg, chordName.getChordName(chordTone));
        // 將生成號的svg圖案塞到指定結構中
        target ? target.appendChild(svg) : document.body.appendChild(svg);
    }
}
複製程式碼

svg也是能使用css修飾部分屬性的,公用樣式得加上:

.chord-svg{
  width: 200px;
  height: 220px;
}
.chord-rect{
  width: 100px;
  height: 100px;
  fill:none;
  stroke:#666;
  stroke-width:2;
}
.chord-gird{
  stroke: #666;
  stroke-width: 2;
  fill: none;
}
.chord-forbidden,.chord-blank-circle{
  stroke: #666;
  stroke-width: 1;
  fill: none;
}
.chord-block-circle{
  fill: #333;
}
.chord-barre{
  height: 16px;
  fill:#333;
}
.chord-barre-fret{
  fill:#333;
  font-size: 14px;
  font-weight: 700;
}
.chord-string-key{
  fill:#333;
  font-size: 10px;
  font-weight: 700;
}
.chord-name{
  fill:#333;
  font-size: 20px;
  font-weight: 700;
}
複製程式碼

哐噹噹一個執行示例:

吉他和絃推導演算法

當然,我怎會止步於此。

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

線上試玩地址

吉他和絃推導演算法

吉他和絃推導演算法

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

吉他和絃推導演算法

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

吉他和絃推導演算法


上過香的尾

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

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

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

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

專案地址:github.com/youngdro/gu…

線上試玩二次傳送門

-------- 以下廣告可以忽略 --------

往期文章:

Three.js粒子特效,shader渲染初探

console覺醒之路,列印個動畫如何?

node基金爬蟲,自導自演瞭解一下?

相關文章