LoginSignup
2
1

More than 1 year has passed since last update.

MIDIからSMFへエンコード/デコードするクラスを作ったのでご紹介

Posted at

はじめに

当記事は、オリジナルのSMFクラスを紹介します。
SMFクラスはタイトルの通り、MIDI信号をStandard MIDI Formatへエンコードする機能と、反対にデコードする機能を持ちます。

これを使うとなにができるの?

JISC-SSDと組み合わせることで、MIDIの録音と再生をおこなうデバイスが作れます。

コード

本章は、SMFクラスと使用例を示します。

SMF.h
SMF.h
Copyright (c) 2022 Masahiro Kusaka All Rights Reserved.

#ifndef SMF_H
#define SMF_H

#include <stdint.h>
#include <string.h>

#define TRACK_NUM 0x0012
#define MAX_TIME_BYYE 0x03

#define Swap2(data) \
( (((data) >> 8) & 0x00FF) | (((data) << 8) & 0xFF00) ) 

#define Swap4(data)   \
( (((data) >> 24) & 0x000000FF) | (((data) >>  8) & 0x0000FF00) | \
  (((data) <<  8) & 0x00FF0000) | (((data) << 24) & 0xFF000000) ) 

#define Swap8(data)   \
( (((data) >> 56) & 0x00000000000000FF) | (((data) >> 40) & 0x000000000000FF00) | \
  (((data) >> 24) & 0x0000000000FF0000) | (((data) >>  8) & 0x00000000FF000000) | \
  (((data) <<  8) & 0x000000FF00000000) | (((data) << 24) & 0x0000FF0000000000) | \
  (((data) << 40) & 0x00FF000000000000) | (((data) << 56) & 0xFF00000000000000) ) 

#pragma pack(push, 1)

typedef struct _header_chunk {
    uint32_t type;
    uint32_t length;
    uint16_t format;
    uint16_t track;
    uint16_t division; 
    //Default menber value (little endian).
    _header_chunk() : type(Swap4(0x4d546864)), length(Swap4(0x00000006)), format(Swap2(0x0001)), track(Swap2(TRACK_NUM)), division(Swap2(0x01e0)) {}
}header_chunk;

typedef struct _track_name {
    uint8_t delta_time;
    uint8_t status;
    uint8_t event;
    uint8_t length;
    //Default menber value.
    _track_name() : delta_time(0x00), status(0xff), event(0x03), length(0x00) {}
}track_name;

typedef struct _port_spec {
    uint8_t delta_time;
    uint8_t status;
    uint8_t event;
    uint16_t port;
    //Default menber value.
    _port_spec() : delta_time(0x00), status(0xff), event(0x21), port(Swap2(0x0100)) {}
}port_spec;

typedef struct _end_of_track {
    uint16_t delta_time;
    uint8_t status;
    uint8_t event;
    uint8_t reserved;
    //Default menber value.
    _end_of_track() : delta_time(Swap2(0x8f00)), status(0xff), event(0x2f), reserved(0x00) {}
}end_of_track;

typedef struct _track_chunk_body {
    track_name name;
    port_spec port;
    end_of_track end;
}track_chunk_body;

typedef struct _track_chunk_header {
    uint32_t type;
    uint32_t length;
    //Default menber value.
    _track_chunk_header() : type(Swap4(0x4d54726b)), length(Swap4((uint32_t)sizeof(track_chunk_body))) {}
}track_chunk_header;

typedef struct _track_chunk {
    track_chunk_header header;
    track_chunk_body body;
}track_chunk;

typedef struct _record_track_body {
    track_name name;
    port_spec port;
    uint8_t notes[1];
}record_track_body;

typedef struct _smf_part1{
    header_chunk header;
    track_chunk_header rec_header;
    record_track_body rec_body;
}smf_part1;

typedef struct _smf_part2 {
    end_of_track end;
    track_chunk track[TRACK_NUM-1];
}smf_part2;

typedef struct _delta_msgs {
    uint8_t length;
    uint8_t msg[MAX_TIME_BYYE];    
}delta_msgs;

#pragma pack(pop)

class SMF
{   public:
        SMF(size_t buf_size);
        void note_pushback(int time, uint8_t midiEvent, uint8_t inNote, uint8_t inVelocity);
        void reset(void);
        smf_part1* get_part1(void);
        uint32_t get_part1_len(void);
        uint8_t* get_all_data(void);
        uint32_t delta2us(uint8_t *msg);

    private:
        void us2delta(int delta_us, delta_msgs *msgs);

        size_t smf_size;
        int prev_time;
        size_t note_idx;
        smf_part1 *part1;
        smf_part2 part2;
};

#endif
SMF.cpp
SMF.cpp
Copyright (c) 2022 Masahiro Kusaka All Rights Reserved.

#include <stdlib.h>
#include <stddef.h>
#include "SMF.h"

SMF::SMF(size_t buf_size)
{
    smf_size = buf_size;
    part1 = (smf_part1 *)calloc(buf_size, 1);
    this->reset();
}

void SMF::us2delta(int delta_us, delta_msgs *msgs)
{

    uint32_t delta_div = delta_us * 3 / 3125;
    if(0x1fffff < delta_div)
        delta_div = 0x1fffff;

    for(uint8_t idx = 0, flag = false, *pt = msgs->msg; idx < 3; idx++)
    {
        uint8_t tmp = (uint8_t)((delta_div << (11 + idx * 7)) >> 25);
        if(tmp || flag)
        {
            *(pt++) = (idx < 2)? tmp + 0x80 : tmp;
            msgs->length++;
            flag = true;
        }
    }
}

uint32_t SMF::delta2us(uint8_t *msg)
{
    uint32_t delta = 0;
    for(int i = 0; i < MAX_TIME_BYYE; i++, delta <<= 7){
        if(msg[i] & 0x80){
            delta += (msg[i] - 0x80);
        }else{
            delta += msg[i];
            break;
        }
    }
    return delta * 3125 / 3;
}

void SMF::note_pushback(int time, uint8_t midiEvent, uint8_t inNote, uint8_t inVelocity)
{
    delta_msgs msgs = {};
    
    us2delta(time - prev_time, &msgs);
    uint8_t push_length = msgs.length + 3;
    
    if( smf_size < (sizeof(smf_part1) + sizeof(smf_part2) + note_idx + push_length) )
        return;

    for(uint8_t i = 0; i < msgs.length; i++)
        part1->rec_body.notes[note_idx++] = msgs.msg[i];

    part1->rec_body.notes[note_idx++] = midiEvent;
    part1->rec_body.notes[note_idx++] = inNote;
    part1->rec_body.notes[note_idx++] = inVelocity;
    part1->rec_header.length = Swap4(get_part1_len() + push_length);
    
    prev_time = time;
}

void SMF::reset(void)
{
    smf_part1 tmp_part1;
    
    prev_time = 0;
    note_idx = 0;
    
    memset(part1, 0x00, sizeof(smf_size));
    memcpy(part1, &tmp_part1, sizeof(smf_part1));
}

smf_part1* SMF::get_part1(void)
{
    return part1;
}

uint8_t* SMF::get_all_data(void)
{
    uint8_t *pt = (uint8_t *)this->get_part1();
    size_t part1_len = offsetof(smf_part1, rec_body) + get_part1_len() - sizeof(end_of_track);
    memcpy(pt + part1_len, &part2, sizeof(part2));
    return pt;
}

uint32_t SMF::get_part1_len(void)
{
    return Swap4(part1->rec_header.length);
}
使用例(MIDI録音再生デバイス)
main.cpp
Copyright (c) 2022 Masahiro Kusaka All Rights Reserved.

#include "mbed.h"
extern "C" {
#include "fmif.h"
}
#include "fmsd1.h"
#include "MIDI.h"

#include "USBMSD_STEP1.h"
#include "SMF.h"

#define ND_CEB P0_9
#define ND_CLE P0_8
#define ND_ALE P1_29
#define ND_WEB P0_11

#define ND_REB P1_21
#define ND_WPB P0_12

#define ND_RBB P1_31
#define ND_IO1 P0_13
#define ND_IO2 P0_14

#define ND_IO3 P1_25
#define ND_IO4 P0_20
#define ND_IO5 P0_2
#define ND_IO6 P1_26
#define ND_IO7 P1_27
#define ND_IO8 P1_23

#define LED_RD  P0_7
#define LED_WR  P1_13
#define SW_INIT P1_20

#define SECTOR_SIZE 512
#define SMF_SIZE (SECTOR_SIZE * 4)

USBMSD_STEP1 *myusb;
SMF *smf;
MIDI midi(P0_19, P0_18);
InterruptIn rec_sw(P1_28);
InterruptIn ply_sw(P1_20);
//DigitalIn ply_sw(P1_20);
DigitalOut rec_status(LED_WR);
uint16_t smf_block = 0;
Timer timer;
Timeout timeout;

//================
void record()
{
    if(rec_sw == 0)
    {//Switch On
        rec_status = 0;
        timer.start();
    }
    else
    {//Switch Off
        uint8_t *smf_data = smf->get_all_data();
        for(size_t offset = 0, i = 0; offset < SMF_SIZE; offset += SECTOR_SIZE, i++)
        {
            myusb->disk_write(((uint8_t *)smf_data) + offset, smf_block + i, 0x00);
        }
        smf->reset();
        timer.stop();
        timer.reset();
        rec_status = 1;
    }
}

uint8_t *notes;
uint32_t note_offset;
uint32_t note_length;

void send_midi()
{
    Fmdriver_sendMidi(notes[note_offset++]); //send Note ON/OFF
    Fmdriver_sendMidi(notes[note_offset++]); //send Note
    Fmdriver_sendMidi(notes[note_offset++]); //send Velocity
    if(note_offset < note_length)
    {
        uint32_t delta_us = smf->delta2us(&notes[note_offset]);
        note_offset += (delta_us < 0x0208d5)? 0x01 : (delta_us < 0x01046aaa)? 0x02 : 0x03;
        timeout.attach_us(send_midi, delta_us);
    }
    else
    {
        smf->reset();
    }
}

void play()
{
    uint8_t *smf_data = (uint8_t *)smf->get_part1();
    for(size_t offset = 0, i = 0; offset < SMF_SIZE; offset += SECTOR_SIZE, i++)
    {
        myusb->disk_read(smf_data + offset, smf_block + i, 0x00);
    }
    notes = smf->get_part1()->rec_body.notes;
    note_offset = 0;
    note_length = smf->get_part1_len() - sizeof(track_chunk_body);

    uint32_t delta_us = smf->delta2us(&notes[note_offset]);
    note_offset += (delta_us < 0x0208d5)? 0x01 : (delta_us < 0x01046aaa)? 0x02 : 0x03;
    timeout.attach_us(send_midi, delta_us);
}

void handleNoteOn(byte inChannel, byte inNote, byte inVelocity)
{
    Fmdriver_sendMidi(0x90);
    Fmdriver_sendMidi(inNote);
    Fmdriver_sendMidi(inVelocity);
    if(rec_sw == 0){
        smf->note_pushback(timer.read_us(), 0x90, inNote, inVelocity);
    }
}

void handleNoteOff(byte inChannel, byte inNote, byte inVelocity)
{
    Fmdriver_sendMidi(0x80);
    Fmdriver_sendMidi(inNote);
    Fmdriver_sendMidi(inVelocity);
    if(rec_sw == 0){
        smf->note_pushback(timer.read_us(), 0x80, inNote, inVelocity);
    }
}
//================


void setup()
{
    initSPI();
    initSD1();
    Fmdriver_init();
    midi.setHandleNoteOn(handleNoteOn);
    midi.setHandleNoteOff(handleNoteOff);
    midi.begin(MIDI_CHANNEL_OMNI);
    
    myusb = new USBMSD_STEP1(ND_CEB,ND_CLE,ND_ALE,ND_WEB,ND_REB,ND_WPB,ND_RBB,
                ND_IO1,ND_IO2,ND_IO3,ND_IO4,ND_IO5,ND_IO6,ND_IO7,ND_IO8,
                LED_RD,LED_WR,SW_INIT);
    
    uint8_t tmp_read[SECTOR_SIZE]={};
    const header_chunk tmp_header;

    for(size_t i; i < 0x100; i++){
        myusb->disk_read(tmp_read, i, 0x00);
        if(memcmp(tmp_read, &tmp_header, sizeof(tmp_header)) == 0){
            smf_block = i;
            break;
        }
    }

    myusb->connect();

    smf = new SMF(SMF_SIZE);

    rec_sw.mode(PullNone);
    rec_sw.fall(record);
    
    ply_sw.mode(PullNone);
    ply_sw.fall(play);
}

int main()
{
    setup();

    while(1) {
        midi.read();
    }
}

コードの説明

本章では、各コードについて説明します。
前提として、Standard MIDI Formatの知識が必要です。
筆者はこちらの解説サイト様を参考にさせていただきました。

SMF.h

SMFクラスは、次の図のような構造体をメモリ上に構成(エンコード)し、NANDに書き込む(保存する)ことを目的としています。

SMF.jpg

SMF.h は、上記構造体の定義が主に記述されています。

#define TRACK_NUM 0x0012
#define MAX_TIME_BYYE 0x03

上記コードは、SMFクラスが構成するデータ構造の設定値になります。
SMFクラスは Format 1 でエンコードするため、TRACK_NUM でトラック数を定義します。
ここでは、Domino という有名シーケンサーソフトのデフォルト値に合わせて18トラックにしています。

ただし、SMFクラスの制限として、1つの楽器演奏をトラック1に記録することしか想定していません。
複数楽器&複数トラックの記録に対応するには、コードの拡張が必要です。
(もしそんな改変をなさる素敵な方がいらっしゃいましたら、是非ご連絡ください!)

SMFクラスは、「いつ、どの音を鳴らすか」という情報(Event)の塊です。
MAX_TIME_BYYE は、「いつ」を表すデルタタイムフィールドに割り当てられるサイズ上限を設定します。
ここでは3 Byteを上限値に設定しているため、SMFファイルのテンポを120、分解能を480に設定する場合、
最大36分程度の時間間隔を表現可能です。
もし分解能を変更する場合、デルタタイムが表現できる時刻幅も当然変わりますのでご注意ください。

#define Swap2(data)
#define Swap4(data)
#define Swap8(data)

#pragma pack(push, 1)

筆者が使用したマイコン(Airio-Base)はSTM32が採用されており、アーキテクチャがリトルエンディアンです。
このため、複数バイトの整数を格納する際は、メモリ上でSwapさせる必要があります。

また、構造体がパッキングされるとエンコード結果がずれてしまうため、アライメントを1に設定します。

typedef struct _smf_part1{
    header_chunk header;
    track_chunk_header rec_header;
    record_track_body rec_body;
}smf_part1;

typedef struct _smf_part2 {
    end_of_track end;
    track_chunk track[TRACK_NUM-1];
}smf_part2;

SMFファイルは演奏データを扱う都合上、ファイルサイズが可変長です。
構造体で可変長のデータを扱うため、ヘッダチャンク〜トラック1の終端までを smf_part1、
トラック1の終端〜全体の終端を smf_part2 に分割して定義します。
演奏データをNANDフラッシュメモリに保存する際、smf_part1 の終端に smf_part2 を memcpy することで、
メモリ上に SMF データが完成します。

SMF.cpp

SMF.cppは、主にデルタタイムの計算と、SMFデータを格納するバッファの管理を行います。

us2delta
void SMF::us2delta(int delta_us, delta_msgs *msgs)
{
    uint32_t delta_div = delta_us * 3 / 3125;
    if(0x1fffff < delta_div)
        delta_div = 0x1fffff;

    for(uint8_t idx = 0, flag = false, *pt = msgs->msg; idx < 3; idx++)
    {
        uint8_t tmp = (uint8_t)((delta_div << (11 + idx * 7)) >> 25);
        if(tmp || flag)
        {
            *(pt++) = (idx < 2)? tmp + 0x80 : tmp;
            msgs->length++;
            flag = true;
        }
    }
}

us2delta()は、マイクロ秒単位で計測した時刻データを、デルタタイムに換算します。
ここでは、関数内で使ってしまっているマジックナンバー 3/3125 の導出方法を説明します。
SMFファイルを編集するソフト(例えばDomino)のテンポが120の場合、1分間に4部音符が120個入るため、4部音符の長さは0.5秒です。
さらに4部音符の分解能が480の場合、デルタタイム 960 の長さが 1 秒と等しくなります。
以上から、デルタタイムの算出は、単なる整数乗算と除算で求められます。

\frac{960 [Tempo]}{1\times10^6 [us]} = \frac{3}{3125}

上記の通り整数除算を用いているため、切り捨てが発生し、デルタタイムはわずかにずれてしまいます。
切り捨て誤差を減らすには、記録する分解能を上げるなどの方法が考えられます。

delta2us
uint32_t SMF::delta2us(uint8_t *msg)
{
    uint32_t delta = 0;
    for(int i = 0; i < MAX_TIME_BYYE; i++, delta <<= 7){
        if(msg[i] & 0x80){
            delta += (msg[i] - 0x80);
        }else{
            delta += msg[i];
            break;
        }
    }
    return delta * 3125 / 3;
}

delta2us()は、デルタタイムからus単位の時刻へ変換する関数で、記録されたSMFを読み出す際に使用します。
SMFフォーマットにおけるデルタタイムは可変長であるため、最上位ビットが 1 の時、次の1Byteも時刻情報として読み出す決まりがあります。

note_pushback
void SMF::note_pushback(int time, uint8_t midiEvent, uint8_t inNote, uint8_t inVelocity)
{
    delta_msgs msgs = {};
    
    us2delta(time - prev_time, &msgs);
    uint8_t push_length = msgs.length + 3;
    
    if( smf_size < (sizeof(smf_part1) + sizeof(smf_part2) + note_idx + push_length) )
        return;

    for(uint8_t i = 0; i < msgs.length; i++)
        part1->rec_body.notes[note_idx++] = msgs.msg[i];

    part1->rec_body.notes[note_idx++] = midiEvent;
    part1->rec_body.notes[note_idx++] = inNote;
    part1->rec_body.notes[note_idx++] = inVelocity;
    part1->rec_header.length = Swap4(get_part1_len() + push_length);
    
    prev_time = time;
}

note_pushback()は、メモリ上に構成したSMFデータに、新しいEventデータを追加します。
例えばMIDI信号を録音する場合、MIDI信号を受信するたびに note_pushback() を実行することで、
演奏データを更新できます。
もし事前に確保したバッファサイズを超過する場合、何もせず return します。

main.cpp

main.cpp は、MIDI信号の録音と再生をおこなうデバイスのプログラムで、SMFクラスを実際に使った例になります。

record
void record()
{
    if(rec_sw == 0)
    {//Switch On
        rec_status = 0;
        timer.start();
    }
    else
    {//Switch Off
        uint8_t *smf_data = smf->get_all_data();
        for(size_t offset = 0, i = 0; offset < SMF_SIZE; offset += SECTOR_SIZE, i++)
        {
            myusb->disk_write(((uint8_t *)smf_data) + offset, smf_block + i, 0x00);
        }
        smf->reset();
        timer.stop();
        timer.reset();
        rec_status = 1;
    }
}

関数record()は、録音開始時に1回、録音停止時に1回呼ばれます。
録音開始時は、デルタタイムの計測に必要なタイマーをスタートさせます。
録音停止時は、メモリ上に作成したSMFデータをNANDフラッシュメモリに記録し、タイマーを停止します。

handleNoteOn/Off
void handleNoteOn(byte inChannel, byte inNote, byte inVelocity)
{
    Fmdriver_sendMidi(0x90);
    Fmdriver_sendMidi(inNote);
    Fmdriver_sendMidi(inVelocity);
    if(rec_sw == 0){
        smf->note_pushback(timer.read_us(), 0x90, inNote, inVelocity);
    }
}

void handleNoteOff(byte inChannel, byte inNote, byte inVelocity)
{
    Fmdriver_sendMidi(0x80);
    Fmdriver_sendMidi(inNote);
    Fmdriver_sendMidi(inVelocity);
    if(rec_sw == 0){
        smf->note_pushback(timer.read_us(), 0x80, inNote, inVelocity);
    }
}

handleNoteOn/Off は、MIDI信号を受信するたびに呼ばれる割り込み関数です。
もしデバイスが録音中ならば、note_pushback 関数でSMFデータを更新します。

play
void play()
{
    uint8_t *smf_data = (uint8_t *)smf->get_part1();
    for(size_t offset = 0, i = 0; offset < SMF_SIZE; offset += SECTOR_SIZE, i++)
    {
        myusb->disk_read(smf_data + offset, smf_block + i, 0x00);
    }
    notes = smf->get_part1()->rec_body.notes;
    note_offset = 0;
    note_length = smf->get_part1_len() - sizeof(track_chunk_body);

    uint32_t delta_us = smf->delta2us(&notes[note_offset]);
    note_offset += (delta_us < 0x0208d5)? 0x01 : (delta_us < 0x01046aaa)? 0x02 : 0x03;
    timeout.attach_us(send_midi, delta_us);
}

関数play()は、録音データの再生開始時に1回だけ呼ばれます。
NANDフラッシュメモリからSMFデータを読み出し、最初の音を鳴らすタイマ割り込み(send_midi)をセットします。

send_midi
void send_midi()
{
    Fmdriver_sendMidi(notes[note_offset++]); //send Note ON/OFF
    Fmdriver_sendMidi(notes[note_offset++]); //send Note
    Fmdriver_sendMidi(notes[note_offset++]); //send Velocity
    if(note_offset < note_length)
    {
        uint32_t delta_us = smf->delta2us(&notes[note_offset]);
        note_offset += (delta_us < 0x0208d5)? 0x01 : (delta_us < 0x01046aaa)? 0x02 : 0x03;
        timeout.attach_us(send_midi, delta_us);
    }
    else
    {
        smf->reset();
    }
}

send_midi()は、Event1個ぶんの音を鳴らしつつ、次の時刻に自分自身のタイマ割り込みをセットします。
演奏が最後まで終わった場合、smfオブジェクトをリセットして終了します。

このコードを使うには?

本章では、当記事のコードを使っていただく場合について説明します。

ライセンス

当記事に掲載したコードは全て、MITライセンスで公開します。

MIT-License
The MIT License (MIT)
Copyright (c) 2022 Masahiro Kusaka All Rights Reserved.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

JISC-SSDを持ってない方

筆者は確認を行っていませんが、素のNANDフラッシュメモリチップの代わりにSDカードを用いても、ほぼ同じことができると思います。
幸い、ArduinoにはSDカードでファイルを読み書きするライブラリが用意されているようなので、JISC-SSDを入手できなかった方はそちらをお使いいただくのが良さそうに思えます。

mbed を使わない方

筆者は確認を行っていませんが、SMFクラスは単純なC++クラスのため、そのままArduino IDEへ移行できると思います。
調べたところ、AVRマイコンもSTM32と同じリトルエンディアンのようなので、SMFクラスの改変もほぼ不要なはずです。
(コード例で示した main.cpp の改変は必須です。)

おわりに

当記事は、オリジナルのSMFクラスを紹介しました。
また、SMFクラスを用いた、MIDI信号の録音再生デバイスの実装例を示しました。
さいごに、当記事で公開したコードのライセンスと使用方法について説明しました。

Maker Faire Tokyo2022 で作品展示をご覧いただいた方から有り難いことに、
「コードを公開してほしい」とご要望をいただいたので、当記事を執筆いたしました。

MIDIの録音再生デバイスについての詳細は、SSD同人誌2号にも解説記事を掲載しておりますので、そちらも併せて是非ご覧ください。
(残念ながら22年9月現在は物理配布のみです。もしも今後Web公開することがあれば、こちらにリンクを記載させていただきます)

ここまで記事をお読みくださり、ありがとうございました。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1