SDL2で簡単作曲ツールを作る
Haskell Advent Calendar 2020,13日目の記事です.
はじめに
夏から楽器の練習を始めましたが,如何せん音痴なので,楽譜を読んでも正しい音程やリズムがわかりません.これでは学習が進みませんね.
そんな障害をツールで解決するのがエンジニアです. 皆大好きHaskellには,これまた皆大好きなSDL2ライブラリが存在します. 音楽がさっぱりわからない私でも使えるGUIツールを作成していきましょう. まだまだ開発中ですが,記事最後にデモ動画を置いてあります.
なお,リポジトリは以下にあります.
では簡単に仕様を決めていきましょう.しかしあまりにも適当に仕様を決めると後々苦しいです. 現実世界の先取り約束機は,等価交換ではないのです.
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 AS
をAddNote 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を参考にテーブルを作成してフォントデータと紐付けました.
デモ動画
ほとんどの関数をGUI.hsに突っ込んでいたり,無駄に処理を繰り返していたり,リファクタリングしないといけない箇所が多々あるんですが,動くものは出来つつあります. 最後にカエルの歌でつくったデモ動画を貼っておきます. 今後,ちまちまと小節毎に区切り線入れるなどの改修も進める予定です.
そういえば五線紙も自分オリジナルで作り直したい...
ついでにロゴも作ってみました,が.雑ですね... 息抜きに絵が描きたかっただけなので,とりあえずは良しとしましょう.
さいごに
音の改良にも手をつけたいところです.各楽器の代表的FFTデータとかどこかに落ちているんだろうか... 音痴はツールが解決しました.やったね!