- 音源を聴きながら別のMIDIアプリで打ち込むと、市販プレイヤーが画面を食い合います。高さ96pxの横長ウインドウに小節、コード、波形を並べた「barcue(バークー)」というアプリを作りました。
- 停止すると直前の練習区切りへ戻る、BPMは小節の頭を二回叩いて逆算する。採譜という作業の実態から機能を決めています。
- LinuxのサブPCでCodex CLIの運用に慣れてから、MacBookへ入れました。Xcodeを開かず、Swift Package ManagerとCommand Line Toolsだけで配布用の
.appまで到達しました。

1. 採譜のじゃまをしないプレイヤーが欲しかった
音源を聴いて楽譜に起こす作業を、採譜と呼びます1。 自分の場合は、音を聴き取ってMIDIアプリの画面に打ち込んでいく。 このとき、音源を再生するアプリと打ち込むアプリを同時に画面へ出しておく必要があります。
ところが、市販の音楽プレイヤーはたいてい正方形に近い形をしています。 アルバムのジャケット画像を大きく出し、曲の一覧を縦に並べる。 音楽を「聴く」ためのレイアウトであって、音楽を「調べる」ためのレイアウトではありません。 MIDIアプリと並べると、どちらも中途半端な大きさになります。
そこで、高さ96pxの横長のウインドウを一枚だけ作りました。 画面の下端に貼り付けておいて、その上でMIDIアプリを開く。 中身は上から順に、小節バー、コードバー、波形バーの三段です。 三段は一つのNSScrollViewの中に置いて、同じ横スクロール位置と同じ時間軸を共有させています2。

1.1. 同じ2小節を20回聴くための停止ボタン
採譜は、短い区間を繰り返し聴く作業です。 2小節を聴く、MIDIアプリに打ち込む、また同じ2小節に戻る。 1曲で何十回もこれをやります。
普通のプレイヤーで困るのは、停止したときに停止した場所へ留まることです。 打ち込みを終えて音源に戻ってくると、再生ヘッドは自分が聴き終えた場所にいる。 毎回、シークバーを掴んで少し前へ戻す操作が挟まります。 これが積み重なると、採譜そのものよりストレスになる。
BarCueの停止ボタンは、押すと再生ヘッドが戻ります。 固定位置があればそこへ、なければ直前の練習区切りへ、それもなければ曲の頭へ。 波形をドラッグして範囲を選んでおけば、その区間を解除するまでループします。 戻る場所を探す操作を、ボタン一つに畳んだわけです。
選んだ範囲と固定位置は、プロジェクトファイルに保存していません。 これらは曲の情報ではなく、「今どこを聴いているか」という一時的な状態だからです。 翌日には別の場所を触っています。
1.2. MIDIアプリ側は小節で考えている


打ち込みをしているとき、頭の中にあるのは「17小節目から24小節目」という言い方です。 一方、音源の側が示してくるのは「02分34秒」。 この二つが結び付かないと、往復のたびに頭の中で変換することになります。
小節バーは、音源に小節番号を貼り付けるための場所です。 波形の上に小節線が引かれ、番号が振られる。 17小節目を聴きたければ、17と書かれたあたりをクリックすればいい。 小節バーでもコードバーでも波形バーでも、クリックした場所へ再生位置が飛びます。
コード欄には拍番号を出していません。 番号は小節バーが持っているので、二か所に同じものを書く理由がない。 拍の位置は、背景の薄い縦線だけで示しています。
練習区切りは、曲の構造の切れ目に付ける印です3。 時間順にA、B、と自動で振られ、Zの次はAAへ続きます。 26進数のような繰り上がりなので、計算はこれだけです。
func rehearsalMark(at index: Int) -> String {
var n = index
var mark = ""
repeat {
mark = String(UnicodeScalar(UInt8(65 + n % 26))) + mark
n = n / 26 - 1
} while n >= 0
return mark
}
Code language: Swift (swift)
番号を自動にしたのは、構造が後から分かるからです。 Bを置いた後で、AとBの間にもう一区切り要ると気づく。 そのたびに手で振り直すのは面倒なので、機械に任せています。
1.3. BPMを知らないから叩いて出す
BPM(Beats Per Minute)は、1分あたりの拍数のことです4。 採譜しようとしている音源のBPMは、当然ながら分かりません。 分かっていたら、そもそも採譜であまり困らない。
タップテンポのように何度も叩いて平均を取る方法もありますが、平均されると細かいずれが消えます。 聴きながら正確に叩けるのは、「この小節の頭」と「次の小節の頭」の二点だけ。 そこでBarCueは、小節の開始位置と終了位置、それと拍子からBPMを逆算します。
func bpm(barStart: TimeInterval, barEnd: TimeInterval, beatsPerBar: Int) -> Double {
Double(beatsPerBar) * 60 / (barEnd - barStart)
}
// 4/4 で 1 小節が 2 秒なら 120
Code language: Swift (swift)
数値を入力する欄はありません。 算出されたBPMは、そのままMIDIアプリ側の設定に打ち込む数字でもあります。 採譜の下ごしらえとして最初に要るものが、最初の操作で手に入る。
1小節が確定すると、そのBPMと拍子で曲の末尾まで小節と拍を自動で並べます。 5分の曲に小節線を手で刻んでいたらキリがないので、ここは伸ばしてしまう。 ただ、人間の演奏は必ずずれます。 ずれを感じた場所で小節の開始位置を打ち直すと、そこから先だけが新しい間隔で引き直されます。 グリッドを正しいものとして信じるのではなく、ずれた場所で局所的に直す道具として作りました。
自分で叩いた小節はオレンジ、自動で並んだ小節は背景に応じた白か黒で描いています。 ずれを感じたとき、直前のオレンジまで戻れば信用できる基準が見つかる。
小節の開始位置を打った瞬間は、次の小節がまだ鳴っていません。 終了位置を叩けるまで、実時間で1小節ぶん待つことになります。 この宙ぶらりんの状態を「保留」として、プロジェクトファイルに保存しました。 中断して翌日開いたときに、何をしかけていたか消えないようにするためです。
コードは、入力した文字列をそのまま保持します。 GmでもBb/Dでも解析しないし、表記も統一しない。 転回形をどう書くか、テンションをどこまで書くかには自分の流儀があるし、迷っている段階のメモもあります。 聴きながら打っている最中に、道具と口論したくない。
2. LinuxのサブPCでCodex CLIに慣れてからMacへ入れた
Codex CLIは、ターミナルの中で対話しながらコードを書かせるツールです5。 ファイルを書き換え、コマンドを実行する。 便利なぶん、実行させる範囲を自分が把握していないと落ち着きません6。
最初に入れたのは、Linux Mintを入れたサブPCでした。 壊しても困らない機械で、承認をどこまで自動にするか、どこで止めるかの勘所を確かめる。 何か月か使って、危ないパターンと安全なパターンの見分けが付くようになってから、MacBookへ入れました。 順番としては、これでよかったと思っています。
2.1. サブPCがLinuxだと、選択肢はクロスプラットフォームに寄る
作業の主戦場がLinuxのサブPCだった間、自作ツールはElectronやTauri、PythonのGUIライブラリで作っていました。 理由は単純で、Swiftで書いてもサブPCで動かないからです。 書いた場所で動かせないものは、育てにくい。
クロスプラットフォームには支払いがあります。 ElectronならChromiumとNode.jsごと同梱するので、小さなツールでも配布物が数百MBになる7。 TauriはOS側のWebViewを使うぶんずっと軽いものの、環境ごとの描画の差を踏むことがあります8。 PythonのGUIは、見た目がどうしてもそのOSの標準から浮く。 どれも「複数のOSで動かす」ための対価です。
今回は、作る機械と使う機械が同じMacBookでした。 その対価を払う理由がない。 SwiftとAppKitで書けば、ファイル選択画面もメニューもスクロールバーも、OSが持っているものがそのまま出てきます9。
3. Swiftのネイティブアプリは軽装で作れる
初日で0.0.1、翌日に0.0.2が動きました。 配布用の.appとZIPまで、2日です。
大がかりな準備が要るという先入観があったのですが、実際に必要だったのはCommand Line Toolsだけでした。
3.1. Xcodeを開かずSwift Package Managerだけで完結する
やっていることは、この二つのコマンドが中心です。
swift build
swift test
Code language: Bash (bash)
Xcodeの完全版は入れていません。 Command Line Toolsに含まれるSwift 6のコンパイラとSwift Package Managerだけで、ビルドもテストも通ります10。 UIもStoryboardを使わず、AppKitのビューをSwiftのコードで組み立てました。 画面をマウスで配置する工程がないぶん、エージェントに書かせやすいという事情もあります。
一つ引っかかったのが、テストの枠組みです。 Command Line ToolsにはXCTestが含まれていません。 そこで、Swift 6から入ったSwift Testingにテストを統一しました11。 書き方は、こういう見た目になります。
import Testing
@Test func rehearsalMarkWrapsAfterZ() {
#expect(rehearsalMark(at: 25) == "Z")
#expect(rehearsalMark(at: 26) == "AA")
}
Code language: Swift (swift)
最終的に27個のテストが走っています。 .appの形に組み立ててZIPまで作るのは、シェルスクリプト1本です。 実行形式がx86_64になっているか、Info.plistの中身、最低対応macOSのバージョンを、生成のたびに検証させています12。
3.2. 音声処理はAVFoundationの部品を並べる
再生周りは、AVFoundationの部品をつなぐだけで組み上がりました13。 プレイヤーの後ろに、速度変更とイコライザーを直列に挿すだけです。
let engine = AVAudioEngine()
let player = AVAudioPlayerNode()
let timePitch = AVAudioUnitTimePitch()
let eq = AVAudioUnitEQ(numberOfBands: 6)
engine.attach(player)
engine.attach(timePitch)
engine.attach(eq)
engine.connect(player, to: timePitch, format: format)
engine.connect(timePitch, to: eq, format: format)
engine.connect(eq, to: engine.mainMixerNode, format: format)
timePitch.rate = 0.5 // 50% 再生。音程は変わらない
Code language: Swift (swift)
これだけで、音程を保ったまま25〜200%の速度変更と、6帯域のイコライザーが手に入ります14。 音の長さと高さを切り離す処理そのものは、自分で書く場面がありませんでした15。 低音を持ち上げてベースを追いたいときは、EQの帯域ごとにゲインを動かします16。
速度を変えても、小節やコードの位置は動きません。 マーカーを画面上のピクセルではなく音源上の時刻として持っているからです。 50%まで落として内声を聴き取り、小節線を確かめて、100%に戻す。 戻したときに位置がずれていたら、確かめた意味がなくなります。
波形の描画は、素直に全部描くと長い曲で止まります。 表示している範囲だけを取り出し、画面上の同じ列に重なるサンプルは最大の振幅へまとめる。
for column in 0..<Int(bounds.width) {
let range = sampleRange(forColumn: column)
peaks[column] = samples[range].lazy.map(abs).max() ?? 0
}
Code language: Swift (swift)
横ズームを一番左まで動かすと曲全体が収まるようにしてあって、このとき1秒が1pxを切ります。 それでも大きな音の塊は見えるので、サビの位置が波形の厚みで分かる。
4. コードより先に、ルールと仕様を書いた
最初に書いたのは、Swiftのコードではありませんでした。 AGENTS.mdに開発方針と禁止事項、spec.mdに製品仕様、dev-log.mdに決めたことと未確認のことを置く。 エージェントに書かせる前に、この三つを固めています。
禁止事項に並べたのは、たとえばこういう項目です。 UIのピクセル座標を音楽データの正本にしないこと。 原因が特定できていない状態で当てずっぽうの修正をしないこと。 根拠のないフォールバックや互換分岐を足さないこと。 どれも、放っておくとコードが静かに膨らんでいく方向の話です。
一つ目は特によく効きました。 小節も拍もコードも音源上の時刻で持つと決めたので、表示位置を求める関数はこれ一つで済みます。
func x(for time: TimeInterval) -> CGFloat {
CGFloat(time) * pixelsPerSecond
}
Code language: Swift (swift)
描画も、クリックの当たり判定も、再生ヘッドの位置も、同じ変換を通します。 ズームしてもスクロールしても、三段の表示がずれる余地がない。
dev-log.mdには、確認できたことと確認できていないことを分けて書いています。 テストが27個通ったことと、実機で音を鳴らして聴感を確かめたかどうかは別の話です。 EQの各帯域が期待どおりに効いているか、96pxのウインドウで文字が読めるかは、自分の耳と目で確かめるしかない。 そこは未確認のまま残してあります。
自分ひとりが使うアプリに、こんな仕様書は要らないと思っていました。 実際に書いてみると、要るのは自分のためではなくて、途中で判断を任せる相手のためでした。 「なぜその形なのか」を先に言葉にしておくと、細部を任せても、作業の実感から外れたものが出てきません。 自分の手に馴染む道具を作るというのは、たぶんそういうことなのだと思います。
- 採譜は、録音された演奏や口伝の音楽を聴き取って楽譜の形にする作業を指します。 – 採譜 – Wikipedia
NSScrollViewはスクロール可能な文書ビューを内包するAppKitのビューで、内側の文書座標と表示範囲を一元的に管理します。 – NSScrollView | Apple Developer Documentation- 練習番号やリハーサルマークは、練習時に演奏を再開する位置を示すためにスコアへ付けられる文字や数字の印です。 – 練習番号 – Wikipedia
- テンポの単位としてのBPMは、1分間に何拍を刻むかを表し、1拍にあたる音符は拍子記号の分母で決まります。 – テンポ – Wikipedia
- Codex CLIはOpenAIが公開しているターミナル向けのコーディングエージェントで、リポジトリ内のファイル編集とコマンド実行を対話的に行います。 – openai/codex – GitHub
- Codex CLIには承認モードとサンドボックスの設定があり、どこまでを確認なしに実行させるかを選べます。 – Codex CLI – OpenAI Developers
- ElectronはChromiumとNode.jsを組み込んで一つのランタイムにまとめる仕組みで、アプリ側のコードが小さくてもこの二つが配布物へ含まれます。 – Electron | Build cross-platform desktop apps with JavaScript, HTML, and CSS
- Tauriはブラウザエンジンを同梱せず、各OSに用意されているWebViewを利用します。 – Tauri 2.0
- AppKitはmacOSのアプリ向けにウインドウ、メニュー、コントロールなどの標準部品を提供するフレームワークです。 – AppKit | Apple Developer Documentation
- Swift Package Managerはビルド、依存解決、テスト実行をSwiftツールチェインの標準機能として提供します。 – Package Manager – Swift.org
- Swift Testingはマクロで書く
@Testと#expectを中心にしたテスト用パッケージで、失敗時に評価された値をそのまま表示します。 – swiftlang/swift-testing – GitHub Info.plistはバンドルの識別子、バージョン、最低対応OSなどをOSへ伝える設定ファイルです。 – Information Property List | Apple Developer DocumentationAVAudioEngineはオーディオノードを接続してグラフを構成し、再生や加工の経路を作るクラスです。 – AVAudioEngine | Apple Developer DocumentationAVAudioUnitTimePitchは再生速度と音程を独立して変えられるエフェクトで、rateだけを変更すれば音程は保たれます。 – AVAudioUnitTimePitch | Apple Developer Documentation- タイムストレッチとピッチシフトは、位相ボコーダなどの手法で音の再生時間と周波数を独立に操作する技術です。 – Audio time stretching and pitch scaling – Wikipedia
AVAudioUnitEQは帯域数を指定して生成し、各バンドの周波数、フィルタ種別、ゲインを個別に設定できます。 – AVAudioUnitEQ | Apple Developer Documentation