Flutterピアノアプリで学ぶ音楽理論とデジタル音響技術
はじめに
個人の趣味としてピアノ学習アプリを開発する過程で、音楽理論とデジタル音響技術が深く結びついていることを実感しました。この記事では、音楽の背後にある数学的・物理的な原理と、それをFlutterで実装する方法を解説します。
録音された音源を使わず、すべての音をリアルタイムで合成することで、アプリサイズを小さく保ちながら5オクターブ分の音域をカバーできる仕組みを実現しました。
| メイン画面 | キーボード | レッスン | 進捗 | 設定 |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
-
開発環境
- Flutter 3.10.0+
- Dart 3.10.0+
- audioplayers 6.1.0
第1章: 音楽の数学的基礎
平均律と周波数の関係
西洋音楽では、1オクターブを12の等しい音程に分割する「平均律」が標準です。この仕組みにより、どの音から始めても同じ音程関係で演奏できます。
周波数計算の理論
基準音A4(ラ)を440Hzとし、半音上がるごとに周波数が
ここで、
-
具体例
- C4(ド): 440 × 2^((60-69)/12) = 261.63Hz
- E4(ミ): 440 × 2^((64-69)/12) = 329.63Hz
- G4(ソ): 440 × 2^((67-69)/12) = 392.00Hz
実装: NoteUtilsクラス
この数学的関係を、Dartのmath.pow関数で実装します。
import 'dart:math' as math;
class NoteUtils {
static double getFrequency(int midiNumber) {
return 440.0 * math.pow(2.0, (midiNumber - 69) / 12.0);
}
}
MIDI番号から音名を抽出する際は、剰余演算を活用します。
static String getNoteNameFromMidi(int midiNumber) {
final noteIndex = midiNumber % 12; // 0-11の範囲
final octave = (midiNumber ~/ 12) - 1;
return '${AppConstants.noteNames[noteIndex]}$octave';
}
- MIDI番号60(C4)の場合
-
60 % 12 = 0→ C -
60 ~/ 12 - 1 = 4→ オクターブ4 - 結果: C4
-
第2章: 音色を決める倍音理論
純音と複合音の違い
単一周波数の正弦波(純音)は、電子音のような無機質な音になります。楽器の豊かな音色は、基音に複数の倍音が重なることで生まれます。
倍音の構成
ピアノの音色を再現するため、以下の倍音構成を採用しました。
- 基音(第1倍音): 振幅 100% - 音の高さを決定
- 第2倍音: 振幅 30% - 明るさを追加
- 第3倍音: 振幅 15% - 豊かさを追加
実装: 倍音の合成
複数の正弦波を加算して、複合音を生成します。
double generateSample(double frequency, double time) {
double value = 0.0;
// 基音
value += math.sin(2.0 * math.pi * frequency * time);
// 第2倍音 (2倍の周波数、30%の振幅)
value += 0.3 * math.sin(2.0 * math.pi * frequency * 2 * time);
// 第3倍音 (3倍の周波数、15%の振幅)
value += 0.15 * math.sin(2.0 * math.pi * frequency * 3 * time);
return value;
}
この単純な加算により、ピアノらしい温かみのある音色が生まれます。
第3章: エンベロープによる時間的変化
ADSR理論
楽器の音は、時間とともに音量が変化します。この変化パターンをエンベロープと呼び、一般的にADSR(Attack, Decay, Sustain, Release)で表現されます。
- ピアノの場合
- Attack(アタック): 打鍵の瞬間、音量が急速に立ち上がる
- Decay(ディケイ): その後、指数関数的に減衰していく
実装: エンベロープ関数
時間変数に応じてエンベロープ係数を計算します。
double calculateEnvelope(double time) {
const attackTime = 0.005; // 5ミリ秒
const decayCoefficient = 2.5;
if (time < attackTime) {
// アタック期: 線形に立ち上がる
return time / attackTime;
} else {
// ディケイ期: 指数関数的に減衰
return math.exp(-decayCoefficient * (time - attackTime));
}
}
この係数を各サンプルの振幅に乗じることで、ピアノ特有の音の減衰を再現します。
final envelope = calculateEnvelope(t);
final sample = (value * envelope * 32767 * 0.5).toInt();
第4章: デジタル音声の基礎
サンプリング定理
アナログ音声をデジタル化する際、サンプリング定理に従う必要があります。人間の可聴域(20Hz〜20,000Hz)をカバーするため、44,100Hzのサンプリングレートを採用しました。
WAVファイル生成の全体フロー
実装: WAVファイル生成
Uint8List _generatePianoWav(double frequency, double duration) {
const int sampleRate = 44100;
final int numSamples = (sampleRate * duration).toInt();
final List<int> wavData = [];
// WAVヘッダー生成
wavData.addAll('RIFF'.codeUnits);
_addInt32(wavData, 36 + numSamples * 2);
wavData.addAll('WAVE'.codeUnits);
wavData.addAll('fmt '.codeUnits);
_addInt32(wavData, 16); // フォーマットチャンクサイズ
_addInt16(wavData, 1); // PCMフォーマット
_addInt16(wavData, 1); // モノラル
_addInt32(wavData, sampleRate);
_addInt32(wavData, sampleRate * 2);
_addInt16(wavData, 2); // ブロックアライン
_addInt16(wavData, 16); // 16ビット
wavData.addAll('data'.codeUnits);
_addInt32(wavData, numSamples * 2);
// 波形データ生成
for (int i = 0; i < numSamples; i++) {
final double t = i / sampleRate;
// 倍音合成
double value = math.sin(2.0 * math.pi * frequency * t);
value += 0.3 * math.sin(2.0 * math.pi * frequency * 2 * t);
value += 0.15 * math.sin(2.0 * math.pi * frequency * 3 * t);
// エンベロープ適用
double envelope;
if (t < 0.005) {
envelope = t / 0.005;
} else {
envelope = math.exp(-2.5 * (t - 0.005));
}
// 16ビット整数に変換
final int sample = (value * envelope * 32767 * 0.5)
.toInt()
.clamp(-32767, 32767);
_addInt16(wavData, sample);
}
return Uint8List.fromList(wavData);
}
- 1.2秒の音の場合
44,100Hz × 1.2秒 × 2バイト = 105,840バイト
第5章: ポリフォニック再生の実現
和音演奏の課題
ピアノでは複数の音を同時に鳴らす必要があります。単一のオーディオプレイヤーでは、新しい音を再生すると前の音が停止してしまいます。
ラウンドロビン方式
8つのAudioPlayerインスタンスをプールとして管理し、順番に使い回す方式を採用しました。
実装: プレイヤープール
class AudioEngine {
final List<AudioPlayer> _playerPool = [];
int _currentPlayerIndex = 0;
Future<void> initialize() async {
// 8つのプレイヤーを作成
for (int i = 0; i < 8; i++) {
final player = AudioPlayer();
await player.setPlayerMode(PlayerMode.lowLatency);
_playerPool.add(player);
}
}
Future<void> playNote(Note note) async {
// ラウンドロビンで次のプレイヤーを選択
final player = _playerPool[_currentPlayerIndex];
_currentPlayerIndex = (_currentPlayerIndex + 1) % _playerPool.length;
// 再生
await player.stop();
await player.play(
DeviceFileSource(filePath),
mode: PlayerMode.lowLatency,
);
}
}
9つ目の音が鳴らされると、最初のプレイヤーが再利用され、最も古い音が自動的に停止されます。これにより、最大8音のポリフォニー演奏が可能になります。
第6章: 音楽教育理論とカリキュラム設計
段階的学習の重要性
音楽教育では、認知負荷を考慮した段階的な学習が効果的です。15のレッスンを5つのレベルに分け、各レベルで特定のスキルに焦点を当てます。
Level 1: 音符認識の基礎
理論: 短期記憶の容量は7±2項目とされています(ミラーの法則)。最初は3音、次に4音、最後に8音と段階的に増やすことで、認知負荷を適切に管理します。
Level 2: メロディ演奏
理論: 既知のメロディを使用することで、聴覚的な記憶を活用できます。「きらきら星」は多くの文化圏で親しまれており、学習者のモチベーション維持に効果的です。
Level 3-5: 段階的なスキル向上
| レベル | 目標正解率 | 推奨時間 | 教育理論 |
|---|---|---|---|
| Level 1 | 80-85% | 3-4分 | 基礎認識・短期記憶 |
| Level 2 | 80-85% | 5-6分 | 聴覚記憶・パターン認識 |
| Level 3 | 85% | 5-6分 | 運動協調性 |
| Level 4 | 85-90% | 5-7分 | 時間認識・リズム感 |
| Level 5 | 90% | 7-8分 | 統合スキル |
実装: 楽曲データの表現
各レッスンの楽曲は、MIDI番号の配列として表現します。
const Lesson(
id: 'lesson_2_1',
title: 'きらきら星(前半)',
description: '誰もが知っている「きらきら星」の前半部分を弾きましょう。',
difficulty: LessonDifficulty.elementary,
type: LessonType.melody,
level: 2,
order: 4,
practiceNotes: [60, 60, 67, 67, 69, 69, 67], // ドドソソララソ
targetAccuracy: 80,
recommendedDuration: 300,
)
この配列を順番に提示し、ユーザーが正しく弾くと次の音に進む仕組みです。
第7章: リズムとメトロノーム
BPMの数学的関係
BPM(Beats Per Minute)は、1分間の拍数を表します。ビート間隔(ミリ秒)との関係は以下の式で表されます。
| BPM値 | ビート間隔 | 用途 |
|---|---|---|
| 40 BPM | 1500ms | ゆっくり練習用 |
| 120 BPM | 500ms | 標準テンポ |
| 208 BPM | 288ms | 高速練習用 |
実装: メトロノームの周期制御
void _startMetronome() {
final intervalMs = (60000 / _bpm).round();
_timer = Timer.periodic(Duration(milliseconds: intervalMs), (timer) {
setState(() {
_beatCount = (_beatCount + 1) % 4; // 4拍子
});
// ダウンビート(1拍目)とアップビートを区別
_playBeep(_beatCount == 0);
});
}
強拍と弱拍の区別
音楽理論では、小節の1拍目を強拍、それ以外を弱拍として区別します。
-
聴覚的な実装
- ダウンビート: 1000Hz、70%音量
- アップビート: 800Hz、40%音量
void _playBeep(bool isDownbeat) {
final frequency = isDownbeat ? 1000.0 : 800.0;
final volume = isDownbeat ? 0.7 : 0.4;
final wavData = _generateClickSound(frequency, 0.05);
// ... 再生処理
}
第8章: データ管理とSingle Source of Truth
進捗管理の設計思想
複数のデータソースがあると、データの不整合が発生しやすくなります。Single Source of Truth(唯一の情報源)パターンを採用し、すべての進捗情報をUserProgressエンティティに集約しました。
UserProgressの構造
リポジトリパターン
実装: レッスン完了の判定
Future<Either<Failure, List<Lesson>>> getAllLessons() async {
final lessons = lessonDataService.getAllLessons();
// UserProgressから完了済みレッスンIDを取得
final completedLessonIdsResult = await _getCompletedLessonIds();
return completedLessonIdsResult.fold(
(failure) => Left(failure),
(completedLessonIds) {
// 完了状態とロック状態を動的に設定
final updatedLessons = lessons.map((lesson) {
final isCompleted = completedLessonIds.contains(lesson.id);
final isLocked = _isLessonLocked(lesson, completedLessonIds);
return lesson.copyWith(
isCompleted: isCompleted,
isLocked: isLocked,
);
}).toList();
return Right(updatedLessons);
},
);
}
練習履歴の詳細記録
UserProgressはアプリ全体の進捗を管理し、PracticeSessionは各練習セッションの詳細を記録します。この二層構造により、高速なアクセスと詳細な分析の両立が可能になります。
第9章: 音楽理論の実装
音階(スケール)の構造
音階は、全音と半音の特定のパターンで構成されます。ハ長調(Cメジャー)は、すべて白鍵で演奏できる最もシンプルな音階です。
| 音名 | C | D | E | F | G | A | B | C |
|---|---|---|---|---|---|---|---|---|
| 日本名 | ド | レ | ミ | ファ | ソ | ラ | シ | ド |
| 音程 | - | 全音 | 全音 | 半音 | 全音 | 全音 | 全音 | 半音 |
| MIDI差 | - | +2 | +2 | +1 | +2 | +2 | +2 | +1 |
全音 = 2半音 = MIDI番号の差2
半音 = 1半音 = MIDI番号の差1
実装: 白鍵の判定
static const List<String> whiteNoteNames = [
'C', 'D', 'E', 'F', 'G', 'A', 'B',
];
static bool isWhiteKey(int midiNumber) {
final noteIndex = midiNumber % 12;
final noteName = AppConstants.noteNames[noteIndex];
return !noteName.contains('#');
}
和音(コード)理論
和音は、音程の数学的関係によって決定されます。
Cメジャーコードの構成:
| 音名 | 役割 | MIDI番号 | 根音からの音程 | 周波数比 |
|---|---|---|---|---|
| C(ド) | 根音 | 60 | - | 1.00 |
| E(ミ) | 第3音 | 64 | 長3度(4半音) | 1.26 |
| G(ソ) | 第5音 | 67 | 完全5度(7半音) | 1.50 |
この音程関係が、和音の「協和感」を生み出します。
実装: 和音の同時再生
ポリフォニック再生システムにより、これらの音を同時に押すと自動的に和音として再生されます。特別な処理は不要で、各音が独立したプレイヤーで再生されます。
第10章: 性能最適化の工夫
キャッシング戦略
毎回音を生成すると、CPUリソースを大量に消費します。以下の二段階戦略でバランスを取ります。
実装: プリロードとオンデマンド生成
Future<void> preloadOctaveRange(int startOctave, int numberOfOctaves) async {
final int startMidi = (startOctave + 1) * 12;
final int endMidi = startMidi + (numberOfOctaves * 12);
for (int midiNumber = startMidi; midiNumber <= endMidi; midiNumber++) {
if (_wavFilePaths.containsKey(midiNumber)) continue;
final frequency = 440.0 * math.pow(2, (midiNumber - 69) / 12.0);
final filePath = '${_tempDir!.path}/piano_$midiNumber.wav';
final file = File(filePath);
if (!await file.exists()) {
final wavData = _generatePianoWav(frequency, 1.2);
await file.writeAsBytes(wavData);
}
_wavFilePaths[midiNumber] = filePath;
}
}
サンプリングレートの使い分け
用途に応じてサンプリングレートを最適化します。
| 用途 | サンプリングレート | ファイルサイズ(1.2秒) | 理由 |
|---|---|---|---|
| ピアノ音 | 44,100Hz | 約106KB | 倍音の表現に高品質が必要 |
| メトロノーム音 | 22,050Hz | 約53KB | 単純な正弦波には十分 |
技術仕様まとめ
音響パラメータ
| パラメータ | 値 | 理論的根拠 |
|---|---|---|
| サンプリングレート(ピアノ) | 44,100Hz | ナイキスト定理(20kHz × 2) |
| サンプリングレート(メトロノーム) | 22,050Hz | 単純波形には十分 |
| ビット深度 | 16ビット | 96dBのダイナミックレンジ |
| 同時発音数 | 8音 | 一般的な和音+メロディ |
| アタック時間 | 5ms | ピアノの打鍵特性 |
| ディケイ係数 | 2.5 | 自然な減衰曲線 |
周波数範囲
| 音名 | MIDI番号 | 周波数(Hz) | 備考 |
|---|---|---|---|
| C2 | 24 | 65.41 | 最低音 |
| C4 | 60 | 261.63 | 中央のド |
| A4 | 69 | 440.00 | 国際標準ピッチ |
| C7 | 96 | 2093.00 | 最高音 |
倍音構成の科学的根拠
| 倍音 | 周波数比 | 振幅比 | 音響心理学的効果 |
|---|---|---|---|
| 基音 | 1倍 | 100% | 音高の知覚 |
| 第2倍音 | 2倍 | 30% | 明るさ・開放感 |
| 第3倍音 | 3倍 | 15% | 豊かさ・温かみ |
まとめ
このピアノアプリの開発を通じて、音楽理論とプログラミング技術の深い関係を実感しました。
-
学んだこと
- 数学と音楽の融合: 平均律の周波数計算は、純粋な数学です
- 物理学の応用: 倍音理論とエンベロープは、音響物理学そのものです
- 認知科学の活用: レッスン設計は、人間の学習メカニズムに基づいています
- 工学的最適化: キャッシング戦略は、パフォーマンスとリソースのトレードオフです
音楽アプリの開発は、理論と実践が交差する魅力的な分野です。この記事が、音楽とプログラミングの両方に興味がある方の参考になれば幸いです。
リポジトリ: GitHub (実装の詳細はこちら)
参考文献
音楽理論・音響学
- 松村誠一郎 編著『音楽制作 - プログラミング・数理・アート』コロナ社、2025年
- 水野正敏『水野式 音楽理論の取扱説明本 全楽器奏者対応』シンコーミュージック、2025年
- 井原恒平『作曲基礎理論 - 専門学校のカリキュラムに基づいて』
デジタル信号処理・音響工学
- 日本音響学会 編『音響入門シリーズ』コロナ社
- 貴家仁志『ディジタル信号処理』オーム社、2014年
技術資料
- Flutter公式ドキュメント: https://docs.flutter.dev/
- audioplayers パッケージドキュメント: https://pub.dev/packages/audioplayers





Discussion