wvogel日記

自分用の技術備忘録が多めです.

SDL2で簡単作曲ツールを作る

Haskell Advent Calendar 2020,13日目の記事です.

はじめに

夏から楽器の練習を始めましたが,如何せん音痴なので,楽譜を読んでも正しい音程やリズムがわかりません.これでは学習が進みませんね.

そんな障害をツールで解決するのがエンジニアです. 皆大好きHaskellには,これまた皆大好きなSDL2ライブラリが存在します. 音楽がさっぱりわからない私でも使えるGUIツールを作成していきましょう. まだまだ開発中ですが,記事最後にデモ動画を置いてあります.

なお,リポジトリは以下にあります.

github.com

では簡単に仕様を決めていきましょう.しかしあまりにも適当に仕様を決めると後々苦しいです. 現実世界の先取り約束機は,等価交換ではないのです.

2108.先取り約束機 - ドラえもん ひみつ道具完全大図鑑

仕様

  • PCキーボードで音階入力(C,D..B)
  • 音長,音程操作
  • 楽譜再生機能
  • 作成した楽譜の保存・読み出し機能(JSON形式)

スラー演奏なども欲しい機能ですが,とりあえず上記があればツールとしては使えるでしょう. また,アプリケーション名は”うずら”にしました.体の割に大きい声で鳴き,鳴き声を競う”鵜合わせ”にも用いられた鳥としても有名です.軽量なアプリで実用に耐えるものを,そんな思いを込めてみましょう.

stackの設定

使用するライブラリをstackで管理します.執筆時点では以下を使用しています.

[package.yaml]
dependencies:
- base >= 4.7 && < 5
- sdl2 >= 2.5.0.0
- sdl2-ttf
- sdl2-image
- aeson
- bytestring
- extra
- linear >= 1.21
- process
- text
- vector
- lens
- directory
- filepath

今回はlensを多用しています.もっと格好良く使えるようになりたい...

イベント型を考える

うずら”には,

  • 作曲画面
  • 保存画面
  • ロード画面

の3つが必要です.

一方,SDL2はIOやIORef使いまくりなので,少しでも安全に開発するために このそれぞれのイベントに対して独立した型を作ります. イベントに対する処理が未実装のものもまだ多数ありますが,次のようにしています.

data QuailEvent =
    Quit
    | NotImplemented
    | AddKeySignature KeySignature
    | AddClef Clef
    | AddNote Scale
    | AddSharp
    | AddFlat
    | Shorten
    | Lengthen
    | DeleteNote
    | AddSign Note
    | AddSlur [Note]
    | AddTie [Note]
    | PlaySound
    | StopSound
    | ResumeSound
    | MousePos Int Int
    | SaveEvent
    | LoadEvent
    deriving (Eq, Show)

data SaveEvent =
    QuitSave
    | Save
    | SaveChoice_Up
    | SaveChoice_Down
    | SaveName_Input Char
    | ContinueSave
    deriving Eq

data LoadEvent =
    QuitLoad
    | Load
    | LoadChoice_Up
    | LoadChoice_Down
    | ContinueLoad
    deriving Eq

楽譜型を考える

楽譜の構造を初めてまともに知りました. 結果,以下の型を採用しました.なお,フィールドは抜粋です. 割と妥当な設計ではないかと思っています.知らんけど.

lensで使うためにはフィールド名の先頭に'_'をつけましょう.

data MusicalScore = MusicalScore
    { _metro :: Metronome
    , _beatRate :: (Int,Int) -- e.g. 4/4拍子
    , _bars :: [Bar]
    }
    deriving (Eq, Show, Generic)

-- JSONのインスタンス化は,以下で登場する型についても同様.
instance FromJSON MusicalScore where
    parseJSON = genericParseJSON jsonOpts
instance ToJSON MusicalScore where
    toEncoding = genericToEncoding jsonOpts

-- テンポ
data Metronome = Metronome Int
    deriving (Eq, Show, Generic)

-- 音部記号
data Clef = GClef | FClef
    deriving (Eq, Show, Generic)

-- 小節
data Bar = Bar
    { _clef :: Clef
    , _keys :: [KeySignature] -- 調号
    , _notes :: [Note]
    }
    deriving (Eq, Show, Generic)

-- 音符 (フィールドは一部抜粋)
data Note = Note
    { _no :: Int
    , _scale :: Scale
    , _sign :: Sign
    , _oct :: Octave
    , _len :: (Length, [Dot])
    , ...
    }
    deriving (Eq, Show, Generic)

-- 音階
data  Scale = Rest | C | CS | D | DS | E | F | FS | G | GS | A | AS | B
    deriving (Eq, Show, Enum, Ord, Generic)

SDL2をゴリゴリ書く

SDL2の使い方についてはC++での解説が充実しているので,公式サイトとその他作例を参考にしつつ実装していきます.描画にはSurface型とRenderer型がありますが,今回は後者を採用しています. GUIの立ち上げ部のコードを掲載します.

startGUI :: IO ()
startGUI = do
    SDL.initializeAll
    withSDL $ withWindow "うずら" (width,height) $
        \w -> do
            sound <- newIORef []
            audioDev <- openAudio sound
            r <- SDL.createRenderer w (-1) rendererConfig
            ts <- loadNoteTextures r
            void $ renderLoop (audioDev,sound) r ts initMusicalScore
            SDL.destroyRenderer r

うずら”では音再生が必要で,SDL2にはそのための関数が用意されています.そしてHaskellでは,音データを格納する値はIORef型となっていますので,ツール起動中の間持ち回せるようにnewIORef関数で領域を確保します. openAudio関数は,音再生に使うデータをコールバック方式で指定する関数です. loadNoteTexturesでは,使用する音符・休符の画像データを読み込んでいます. renderLoop関数で描画ループに突入するので,イベントに応じて処理を書いていきます.

キーイベントに応じたイベント型を得る関数getEventTypeを一部抜粋します.

getEventType :: SDL.KeyboardEventData -> QuailEvent
getEventType event
    | catchOn event (Just SDL.keyModifierLeftShift, SDL.KeycodeA,   SDL.Pressed) = AddNote AS
    | catchOn event (Nothing,                       SDL.KeycodeA,   SDL.Pressed) = AddNote A
    | ...
    | catchOn event (Nothing,                       SDL.KeycodeP,   SDL.Pressed) = PlaySound
    | catchOn event (Nothing,                       SDL.KeycodeLeft,  SDL.Pressed) = Shorten
    | ...

最初の二行は音階を追加する場合のものですが,ここでAddNote ASAddNote Aよりも下に置くと,shiftキーを押してもAddNote Aを返してしまいますので注意してください.

ここで得たイベント型に応じて捌くのがdealEventです.全てのケースを掲載する必要もないのでこれも抜粋を掲載します.

dealEvent :: QuailEvent -> (SDL.AudioDevice, IORef [Int16]) -> SDL.Renderer -> MusicalScore -> IO MusicalScore
dealEvent ev (audio,sound) r ms = case ev of
    PlaySound   -> do
        buildMusic ms sound
        playAudio audio sound >> return ms
    StopSound   -> lockAudio audio >> return ms
    ResumeSound -> resumeAudio audio >> return ms
    ...
    AddSharp -> return $ addSharp (noteCount ms) ms
    AddFlat  -> return $ addFlat  (noteCount ms) ms
    Shorten  -> return $ shorten  (noteCount ms) ms
    Lengthen -> return $ lengthen (noteCount ms) ms
    LoadEvent -> do
        loadResult <- loadLoop r
        case loadResult of
            Just loadedScore -> return loadedScore
            Nothing -> return ms
    SaveEvent -> saveLoop r ms >> return ms
    _ -> return ms

なお,現時点ではAddSharp等の音符操作は,すべて楽譜の一番最後の音符についてのみの操作になっています.addSharp関数に渡す第一引数が,操作する音符の場所を示す値なので,将来的にマウスイベントなどを実装する場合には,ここを変更することで操作対象を変更します. また,音符や小節はリストに包まれているので何も考えずにlastやinitを使うと,楽譜に音符が存在しない時にエラーで落ちてしまいます.そこで少々面倒ですがsafelast,safeinit関数で安全に捌いています.

文字描画

適当にフォントデータ(ttf形式は動作確認済)をダウンロードし,適当なフォルダに格納します.格好良いフォントを探しましょう. フォントサイズが決まれば,SDL.Font.size関数で文字描画に最適な描画エリアが求まります.今回は再利用しやすいように文字描画用の関数を用意しました.SDL.V2はCInt型を引数に取るので,煩わしいですがfromIntegral関数で型を合わせてあげます.

drawText r color fontsize (x,y) text = unless (T.null text) $ do
    font <- Font.load fontpath fontsize
    textSurface <- Font.solid font color text
    (w,h) <- Font.size font text
    textTexture <- SDL.createTextureFromSurface r textSurface
    Font.free font
    SDL.copyEx r textTexture
        (Just $ SDL.Rectangle (SDL.P $ SDL.V2 0 0) (SDL.V2 (fromIntegral w) (fromIntegral h)))
        (Just $ SDL.Rectangle (SDL.P $ SDL.V2 x y) (SDL.V2 (fromIntegral w) (fromIntegral h)))
        0
        Nothing
        (pure False)

ちなみにこの関数,中で毎回フォントデータを読んでいるのであまりよろしくないです. いずれ直します,いずれ...

ただ,キーボードからの入力に応じた文字を描画するのは少々面倒です.楽譜を保存する時に必要になるのですが,sdlライブラリから得られるのは入力されたキーに対応するキーコードですので,以下URLを参考にテーブルを作成してフォントデータと紐付けました.

SDLキーコード一覧表

デモ動画

ほとんどの関数をGUI.hsに突っ込んでいたり,無駄に処理を繰り返していたり,リファクタリングしないといけない箇所が多々あるんですが,動くものは出来つつあります. 最後にカエルの歌でつくったデモ動画を貼っておきます. 今後,ちまちまと小節毎に区切り線入れるなどの改修も進める予定です.

そういえば五線紙も自分オリジナルで作り直したい...

youtu.be

ついでにロゴも作ってみました,が.雑ですね... 息抜きに絵が描きたかっただけなので,とりあえずは良しとしましょう. f:id:wvogel00:20201211233121p:plain

さいごに

音の改良にも手をつけたいところです.各楽器の代表的FFTデータとかどこかに落ちているんだろうか... 音痴はツールが解決しました.やったね!