1. 程式人生 > >將洛奇的MML樂譜轉為beep(蜂鳴器)樂譜

將洛奇的MML樂譜轉為beep(蜂鳴器)樂譜

最近對蜂鳴器音樂感興趣,但是找不到樂譜,於是想把其他樂譜轉為蜂鳴器樂譜。直接用MIDI轉換很困難,因為MIDI一個音軌可以同時發出不同的音,所以我想到了用以前玩過的遊戲中的樂譜(MML樂譜參考

轉換器製作

音高頻率表

首先要知道每個音高對應的頻率,按照十二平均律算,標準音高A4是440Hz,一個半音相差21/12倍,一個八度相差2倍

FREQ_TABLE = [[0 for scale in range(12)] for octave in range(9)]


def gen_freq_table():
    global FREQ_TABLE
    # A4標準音高
    FREQ_TABLE[
4][9] = 440 # 十二平均律 for scale in range(8, -1, -1): FREQ_TABLE[4][scale] = FREQ_TABLE[4][scale + 1] / 2 ** (1 / 12) for scale in range(10, 12): FREQ_TABLE[4][scale] = FREQ_TABLE[4][scale - 1] * 2 ** (1 / 12) for octave in range(3, -1, -1): for scale in range(12): FREQ_TABLE[
octave][scale] = FREQ_TABLE[octave + 1][scale] / 2 for octave in range(5, 9): for scale in range(12): FREQ_TABLE[octave][scale] = FREQ_TABLE[octave - 1][scale] * 2 for octave in range(9): for scale in range(12): FREQ_TABLE[octave][scale] = round(FREQ_TABLE[
octave][scale]) gen_freq_table()

生成表後直接硬編碼即可

# 音程 -> 音階 -> 頻率,按十二平均律算
FREQ_TABLE = [
    [16, 17, 18, 19, 21, 22, 23, 24, 26, 28, 29, 31],  # C0~B0(未使用)
    [33, 35, 37, 39, 41, 44, 46, 49, 52, 55, 58, 62],  # C1~B1
    [65, 69, 73, 78, 82, 87, 92, 98, 104, 110, 117, 123],
    [131, 139, 147, 156, 165, 175, 185, 196, 208, 220, 233, 247],
    [262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494],  # C4~B4
    [523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988],
    [1047, 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976],
    [2093, 2217, 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951],
    [4186, 4435, 4699, 4978, 5274, 5588, 5920, 6272, 6645, 7040, 7459, 7902]
]

詞法分析、語法分析

因為巨集語言比較簡單,這兩部分就和在一起寫了。輸入MML字串,輸出音符、預設音長等token

# Token等定義略

class SyntaxAnalyzer:
    """語法分析器(兼詞法分析器)"""

    # 字元 -> 音階索引
    SCALE = {
        'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11
    }

    def __init__(self):
        self._mml = ''
        self._index = 0
        self._line = 1
        self._col = 1

        self._res = [[]]

    def _preprocess(self):
        self._mml = self._mml.upper()
        self._index = 0
        self._line = 1
        self._col = 1
        if self._mml.startswith('[email protected]'):
            self._index += 4
            self._col += 4

        self._res = [[]]

    def parse(self, mml: str) -> List[List[Token]]:
        """返回音軌陣列,每個音軌包含多個Token"""

        self._mml = mml
        self._preprocess()

        while self._index < len(self._mml):
            line = self._line
            col = self._col
            if self._cur_char == ',':
                # 新音軌
                self._read_char()
                self._res.append([])

            elif self._cur_char == ';':
                # 結束
                self._read_char()
                break

            elif self._cur_char in 'CDEFGAB':
                # 音符
                self._output(self._read_note())

            elif self._cur_char == 'R':
                # 休止符
                self._read_char()
                self._output(Pause(line, col, self._read_length()))

            elif self._cur_char == '&':
                # 連線
                self._read_char()
                self._output(Joiner(line, col))

            elif self._cur_char == 'L':
                # 預設音長
                self._read_char()
                self._output(DefaultLength(line, col, self._read_length()))

            elif self._cur_char == 'T':
                # 播放速度
                self._read_char()
                tempo = self._read_number()
                if tempo is not None and not 32 <= tempo <= 255:
                    raise MmlError(line, col, f'tempo = {tempo},速度超出範圍')
                self._output(Tempo(line, col, tempo))

            elif self._cur_char == 'V':
                # 音量
                self._read_char()
                volume = self._read_number()
                if volume is not None and not 0 <= volume <= 15:
                    raise MmlError(line, col, f'volume = {volume},音量超出範圍')
                self._output(Volume(line, col, volume))

            elif self._cur_char == 'O':
                # 音程
                self._read_char()
                octave = self._read_number()
                if octave is not None and not 1 <= octave <= 8:
                    raise MmlError(line, col, f'octave = {octave},音程超出範圍')
                self._output(Octave(line, col, octave))

            elif self._cur_char in '><':
                # 改變音程
                self._output(ChangeOctave(line, col, 1 if self._cur_char == '>' else -1))
                self._read_char()

            elif self._cur_char == 'N':
                # 絕對音高
                self._read_char()
                note_index = self._read_number()
                if note_index is None or not 1 <= note_index <= 96:
                    raise MmlError(line, col, f'note_index = {note_index},絕對音高超出範圍')
                self._output(AbsoluteNote(line, col, note_index))

            elif self._cur_char in ' \t\r':
                # 空白
                self._read_char()

            elif self._cur_char == '\n':
                # 新行
                self._read_char()
                self._line += 1
                self._col = 1

            else:
                # 錯誤字元
                raise MmlError(line, col, f'未預料到的字元"{self._cur_char}"')

        return self._res
    
    def _output(self, token):
        self._res[-1].append(token)
    
    def _read_char(self):
        res = self._mml[self._index]
        self._index += 1
        self._col += 1
        return res
    
    @property
    def _cur_char(self):
        return self._mml[self._index]

    def _read_number(self):
        """讀數字,非數字則返回None"""

        if self._index >= len(self._mml) or not '0' <= self._cur_char <= '9':
            return None
        res = 0
        while self._index < len(self._mml) and '0' <= self._cur_char <= '9':
            res = res * 10 + int(self._cur_char)
            self._read_char()
        return res

    def _read_length(self):
        length = self._read_number()
        has_dot = False
        if self._index < len(self._mml) and self._cur_char == '.':
            has_dot = True
            self._read_char()
        return Length(length, has_dot)

    def _read_note(self):
        line = self._line
        col = self._col

        scale = self.SCALE[self._cur_char]
        self._read_char()
        if self._index < len(self._mml):
            if self._cur_char in '+#':
                scale += 1
                self._read_char()
            elif self._cur_char == '-':
                scale -= 1
                self._read_char()
        return Note(line, col, scale, self._read_length())

token轉換為beep譜

這裡要考慮tempo是所有音軌共用的,後面的音軌優先,所以每次選事件最小的音軌處理,如果時間相同則選擇後面的音軌

按照4分音符為一拍,持續時間計算方法:duration(ms)=60(s/min)/tempo(beat/min)4(beat/)/length(1)1000(ms/s)duration_{(ms)} = 60_{(s/min)} / tempo_{(beat/min)} * 4_{(beat/全音符)} / length_{(全音符^{-1})} * 1000_{(ms/s)}

class MmlParser:
    class _Track:
        def __init__(self, tokens):
            self.tokens = tokens
            self.index = 0
            # 音程
            self.octave = DEFAULT_OCTAVE
            # 預設音長
            self.default_length = DEFAULT_LENGTH
            # 播放速度(BPM,一分鐘幾拍)
            self.tempo = None
            # 下一個音符開始時間
            self.time = 0
            # 前面有一個連線符,正在連線狀態
            self.is_joining = False
            self.beep_res = []

        def output(self, frequency, duration):
            self.time += duration
            if not self.is_joining:
                self.beep_res.append([frequency, duration])
                return
            if not self.beep_res:
                raise MmlError(self.tokens[self.index].line, self.tokens[self.index].col, '缺少被連線的音符')
            if self.beep_res[-1][0] == frequency:
                self.beep_res[-1][1] += duration
            else:
                self.beep_res.append([frequency, duration])
            self.is_joining = False

    def __init__(self):
        self._tracks = []

    def parse(self, mml: str) -> List[List[List[int]]]:
        """轉換MML樂譜到beep譜

        返回音軌陣列,每個音軌包含多個音符,每個音符為[頻率(Hz), 持續時間(ms)],頻率為0代表延時
        """

        self._tracks = [self._Track(tokens) for tokens in SyntaxAnalyzer().parse(mml)]

        track = self._get_next_track_to_process()
        while track is not None:
            token = track.tokens[track.index]
            if isinstance(token, Note):
                # 音符
                octave = track.octave
                scale = token.scale
                if scale < 0:
                    scale = 11
                    octave -= 1
                elif scale >= 12:
                    scale = 0
                    octave += 1
                if not 1 <= octave <= 8:
                    raise MmlError(token.line, token.col, f'octave = {octave},音程超出範圍')
                track.output(FREQ_TABLE[octave][scale], self._get_duration(track, token.length))

            elif isinstance(token, Pause):
                # 休止符
                track.output(0, self._get_duration(track, token.length))

            elif isinstance(token, Joiner):
                # 連線
                track.is_joining = True