Pythonで作る8ビット風ゲーム音楽ジェネレーター：スペースシューター編

## はじめに

レトロゲームの魅力の一つである8ビットサウンド。そのノスタルジックな音色は、多くのゲーマーの心を掴んで離しません。今回は、Pythonを使って8ビット風のゲーム音楽を生成する方法を紹介します。特に、スペースシューターゲームをイメージした約45秒の曲を作成していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/e9f0fc03-3512-427b-3d35-5cdfa0ba20b6.png)


## 要件と仕様

このプロジェクトを始める前に、明確な要件と仕様を定義しました。これにより、目的に沿った実装が可能になり、また読者の皆さんにもプロジェクトの意図をより良く理解していただけると思います。

### 要件：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/64a87704-e64e-4595-4a6d-fcd116f8cd44.png)

1. Pythonを使用して、8ビット風の音楽を生成すること。
2. スペースシューターゲームに適した雰囲気の音楽を作ること。
3. 楽曲の長さは約45秒とし、ゲームの進行に合わせたストーリー性を持たせること。
4. 生成した音楽をMP3形式で保存できること。
5. コードは再利用可能で、カスタマイズしやすい構造にすること。

### 仕様：

1. 音声合成：
   - 矩形波を使用して8ビット風の音を生成する。
   - ADSRエンベロープを適用して、音の立ち上がりと減衰を自然にする。

2. 音楽構成：
   - イントロ（宇宙船の出発）：約8秒
   - メイン戦闘シーン：約16秒
   - ボス登場のジングル：約4秒
   - ボス戦：約12秒
   - エンディング（勝利のファンファーレ）：約5秒

3. 使用音階：
   - C3からG6までの音符を使用可能とする。

4. テンポ：
   - 基本テンポを150 BPMとする。

5. トラック構成：
   - メロディトラックとベースライントラックの2トラックを使用。

6. 出力形式：
   - 44.1kHz、16ビット、ステレオのMP3ファイル。

7. ライブラリ：
   - numpy: 数値計算用
   - scipy: 信号処理用
   - pydub: オーディオセグメント操作用
   - simpleaudio: オーディオ再生用

これらの要件と仕様に基づいて実装を行いました。では、具体的な実装の詳細を見ていきましょう。

## 必要な環境

まず、以下のライブラリをインストールする必要があります：

```bash
pip install numpy scipy pydub simpleaudio
```

## 実装の解説

### 1. 音符と周波数の定義

まず、音符とその周波数を定義します。これにより、音楽理論の知識がなくても、直感的に曲を作ることができます。

```python
NOTES = {
    'C3': 130.81, 'C#3': 138.59, 'D3': 146.83,
    # ... 中略 ...
    'F#6': 1479.98, 'G6': 1567.98
}
```

### 2. 波形の生成

8ビット風の音を作るために、矩形波を使用します。

```python
def create_square_wave(frequency, duration, duty_cycle=0.5, sample_rate=44100):
    t = np.linspace(0, duration, int(sample_rate * duration), False)
    wave = signal.square(2 * np.pi * frequency * t, duty=duty_cycle)
    return wave
```

### 3. エンベロープの適用

音の立ち上がりと減衰を自然にするために、ADSR（Attack, Decay, Sustain, Release）エンベロープを適用します。

```python
def apply_envelope(wave, attack=0.01, decay=0.1, sustain=0.7, release=0.1):
    # ... エンベロープの計算と適用 ...
```

### 4. メロディの作成

メロディは音符と長さのペアのリストとして定義します。ここでは、ゲームの各シーンに合わせてメロディを構成しています。

```python
intro_melody = [
    ('C5', 0.25), ('E5', 0.25), ('G5', 0.25), ('C6', 0.25),
    ('B5', 0.5), ('G5', 0.5),
    # ... 続く ...
]

# メイン戦闘シーン、ボス登場ジングル、ボス戦、エンディングも同様に定義
```

### 5. ベースラインの追加

メロディに深みを加えるために、シンプルなベースラインを追加します。

```python
def create_bass_line(tempo=120):
    bass_pattern = [
        ('C3', 0.5), ('G3', 0.5), ('C4', 0.5), ('G3', 0.5),
        # ... 続く ...
    ]
    return create_melody(bass_pattern, tempo)
```

### 6. トラックのミキシング

メロディとベースラインを組み合わせて、最終的な音楽トラックを作成します。

```python
def mix_tracks(*tracks):
    mixed = AudioSegment.silent(duration=max(track.duration_seconds for track in tracks) * 1000)
    for track in tracks:
        mixed = mixed.overlay(track)
    return mixed
```

## 使用方法

完全な実装を `space_shooter_music_generator.py` として保存し、以下のように実行します：

```python
python space_shooter_music_generator.py
```

実行すると、約45秒のスペースシューターゲーム風の音楽が生成され、再生された後、MP3ファイルとして保存されます。

<details>
<summary>コードを表示</summary>

```python
import numpy as np
from scipy import signal
from pydub import AudioSegment
import simpleaudio as sa

# 音符と周波数の定義（E6まで拡張）
NOTES = {
    'C3': 130.81, 'C#3': 138.59, 'D3': 146.83, 'D#3': 155.56, 'E3': 164.81, 'F3': 174.61,
    'F#3': 185.00, 'G3': 196.00, 'G#3': 207.65, 'A3': 220.00, 'A#3': 233.08, 'B3': 246.94,
    'C4': 261.63, 'C#4': 277.18, 'D4': 293.66, 'D#4': 311.13, 'E4': 329.63, 'F4': 349.23,
    'F#4': 369.99, 'G4': 392.00, 'G#4': 415.30, 'A4': 440.00, 'A#4': 466.16, 'B4': 493.88,
    'C5': 523.25, 'C#5': 554.37, 'D5': 587.33, 'D#5': 622.25, 'E5': 659.25, 'F5': 698.46,
    'F#5': 739.99, 'G5': 783.99, 'G#5': 830.61, 'A5': 880.00, 'A#5': 932.33, 'B5': 987.77,
    'C6': 1046.50, 'C#6': 1108.73, 'D6': 1174.66, 'D#6': 1244.51, 'E6': 1318.51, 'F6': 1396.91,
    'F#6': 1479.98, 'G6': 1567.98
}

def create_square_wave(frequency, duration, duty_cycle=0.5, sample_rate=44100):
    t = np.linspace(0, duration, int(sample_rate * duration), False)
    wave = signal.square(2 * np.pi * frequency * t, duty=duty_cycle)
    return wave

def apply_envelope(wave, attack=0.01, decay=0.1, sustain=0.7, release=0.1):
    total_length = len(wave)
    attack_length = int(attack * total_length)
    decay_length = int(decay * total_length)
    release_length = int(release * total_length)
    sustain_length = total_length - attack_length - decay_length - release_length
    
    envelope = np.concatenate([
        np.linspace(0, 1, attack_length),
        np.linspace(1, sustain, decay_length),
        np.ones(sustain_length) * sustain,
        np.linspace(sustain, 0, release_length)
    ])
    
    return wave * envelope

def create_tone(note, duration, volume=-20, duty_cycle=0.5):
    sample_rate = 44100
    try:
        frequency = NOTES[note]
    except KeyError:
        print(f"警告: 未定義の音符 '{note}' です。C4を代わりに使用します。")
        frequency = NOTES['C4']  # デフォルトの音符として C4 を使用
    
    wave = create_square_wave(frequency, duration, duty_cycle, sample_rate)
    wave = apply_envelope(wave)
    wave = np.int16(wave * 32767 * 10**(volume/20))
    return AudioSegment(wave.tobytes(), frame_rate=sample_rate, sample_width=2, channels=1)

def create_melody(notes, tempo=120):
    melody = AudioSegment.silent(duration=0)
    beat_duration = 60 / tempo
    
    for note, duration in notes:
        if note == 'R':
            tone = AudioSegment.silent(duration=int(duration * beat_duration * 1000))
        else:
            tone = create_tone(note, duration * beat_duration)
        melody += tone
    
    return melody

# イントロ部分（宇宙船の出発）
intro_melody = [
    ('C5', 0.25), ('E5', 0.25), ('G5', 0.25), ('C6', 0.25),
    ('B5', 0.5), ('G5', 0.5),
    ('A5', 0.25), ('F5', 0.25), ('D5', 0.25), ('B4', 0.25),
    ('G4', 0.5), ('C5', 0.5),
] * 2

# メイン戦闘シーン
battle_melody = [
    ('D5', 0.25), ('F5', 0.25), ('A5', 0.25), ('C6', 0.25),
    ('B5', 0.5), ('G5', 0.5),
    ('A5', 0.25), ('F5', 0.25), ('D5', 0.25), ('B4', 0.25),
    ('C5', 0.5), ('E5', 0.5),
] * 4

# ボス登場のジングル
boss_jingle = [
    ('C5', 0.25), ('C5', 0.25), ('C5', 0.25), ('R', 0.25),
    ('G4', 0.5), ('G4', 0.5),
    ('C5', 0.25), ('B4', 0.25), ('A4', 0.25), ('G4', 0.25),
    ('F4', 0.5), ('R', 0.25), ('F5', 0.75),
]

# ボス戦
boss_battle = [
    ('C6', 0.25), ('A5', 0.25), ('F5', 0.25), ('C5', 0.25),
    ('D5', 0.5), ('G5', 0.5),
    ('B5', 0.25), ('G5', 0.25), ('E5', 0.25), ('C5', 0.25),
    ('A4', 0.5), ('D5', 0.5),
] * 3

# エンディング（勝利のファンファーレ）
ending = [
    ('C5', 0.5), ('E5', 0.5), ('G5', 0.5), ('C6', 0.5),
    ('G5', 0.5), ('C6', 0.5), ('E6', 1.0),
    ('C6', 0.25), ('C6', 0.25), ('C6', 0.25), ('C6', 0.25),
    ('C6', 1.0), ('R', 1.0)
]

# 完全なメロディ
full_melody = intro_melody + battle_melody + boss_jingle + boss_battle + ending

def create_bass_line(tempo=120):
    bass_pattern = [
        ('C3', 0.5), ('G3', 0.5), ('C4', 0.5), ('G3', 0.5),
        ('A3', 0.5), ('E3', 0.5), ('A3', 0.5), ('E3', 0.5),
        ('F3', 0.5), ('C3', 0.5), ('F3', 0.5), ('C3', 0.5),
        ('G3', 0.5), ('D3', 0.5), ('G3', 0.5), ('D3', 0.5),
    ] * 6  # 6回繰り返して約45秒の長さにする
    return create_melody(bass_pattern, tempo)

def mix_tracks(*tracks):
    mixed = AudioSegment.silent(duration=max(track.duration_seconds for track in tracks) * 1000)
    for track in tracks:
        mixed = mixed.overlay(track)
    return mixed

def play_audio(audio):
    play_obj = sa.play_buffer(
        audio.raw_data,
        num_channels=audio.channels,
        bytes_per_sample=audio.sample_width,
        sample_rate=audio.frame_rate
    )
    play_obj.wait_done()

def save_audio_as_mp3(audio, filename):
    audio.export(filename, format="mp3")

def main():
    print("拡張版スペースシューターゲーム風の音楽を生成します...")
    
    tempo = 150
    
    # メロディの生成
    melody = create_melody(full_melody, tempo=tempo)
    
    # ベースラインの生成
    bass_line = create_bass_line(tempo=tempo)
    
    # メロディとベースラインをミックス
    final_track = mix_tracks(melody, bass_line)
    
    # 音量調整
    final_track = final_track - 3  # 全体の音量を少し下げる
    
    print("スペースシューターゲームの音楽を再生します...")
    play_audio(final_track)
    
    print("音楽をMP3ファイルとして保存します...")
    save_audio_as_mp3(final_track, "extended_space_shooter_music.mp3")
    
    print("処理が完了しました。extended_space_shooter_music.mp3が作成されました。")

if __name__ == "__main__":
    main()
```
</details>

## 設計
### クラス図

```mermaid
classDiagram
    class Note {
        +frequency: float
        +duration: float
    }

    class WaveGenerator {
        +generate_square_wave()
        +generate_triangle_wave()
        +generate_sawtooth_wave()
    }

    class Envelope {
        +attack: float
        +decay: float
        +sustain: float
        +release: float
        +apply_envelope(wave)
    }

    class Melody {
        +notes: List[Note]
        +create_melody()
    }

    class Bassline {
        +pattern: List[Note]
        +create_bassline()
    }

    class Mixer {
        +tracks: List[AudioSegment]
        +add_track(track)
        +mix()
    }

    Note --> Melody : used in
    Note --> Bassline : used in
    WaveGenerator --> Melody : generates waves for
    WaveGenerator --> Bassline : generates waves for
    Envelope --> Melody : applies envelope to
    Envelope --> Bassline : applies envelope to
    Melody --> Mixer : added to
    Bassline --> Mixer : added to
```

### クラス概要

| クラス名 | 説明 |
|---------|------|
| Note | 音符の基本情報を表現 |
| WaveGenerator | 異なる種類の波形を生成 |
| Envelope | ADSRエンベロープを適用 |
| Melody | メロディを作成・管理 |
| Bassline | ベースラインを作成・管理 |
| Mixer | 複数の音楽トラックを混合 |

### クラス詳細

#### Note

| 属性/メソッド | 型 | 説明 |
|-------------|-----|------|
| frequency | float | 音の高さ（周波数） |
| duration | float | 音の長さ |

#### WaveGenerator

| メソッド | 説明 |
|---------|------|
| generate_square_wave() | 矩形波を生成 |
| generate_triangle_wave() | 三角波を生成 |
| generate_sawtooth_wave() | のこぎり波を生成 |

#### Envelope

| 属性/メソッド | 型/説明 |
|-------------|--------|
| attack | float（音の立ち上がり時間） |
| decay | float（最大音量から持続音量までの減衰時間） |
| sustain | float（持続音量のレベル） |
| release | float（キーを離してから音が消えるまでの時間） |
| apply_envelope(wave) | 与えられた波形にエンベロープを適用 |

#### Melody

| 属性/メソッド | 型/説明 |
|-------------|--------|
| notes | List[Note]（メロディを構成する音符のリスト） |
| create_melody() | 音符のリストからメロディを生成 |

#### Bassline

| 属性/メソッド | 型/説明 |
|-------------|--------|
| pattern | List[Note]（ベースラインのパターンを表す音符のリスト） |
| create_bassline() | パターンからベースラインを生成 |

#### Mixer

| 属性/メソッド | 型/説明 |
|-------------|--------|
| tracks | List[AudioSegment]（混合する音楽トラックのリスト） |
| add_track(track) | ミキサーに新しいトラックを追加 |
| mix() | すべてのトラックを混合して最終的な音楽を生成 |

### シーケンス図
```plantuml
@startuml
actor Main
participant Melody
participant Bassline
participant Note
participant WaveGenerator
participant Envelope
participant Mixer

Main -> Melody : create_melody()
activate Melody
Melody -> Note : create(frequency, duration)
activate Note
Note --> Melody : note
deactivate Note
Melody -> WaveGenerator : generate_square_wave()
activate WaveGenerator
WaveGenerator --> Melody : wave
deactivate WaveGenerator
Melody -> Envelope : apply_envelope(wave)
activate Envelope
Envelope --> Melody : enveloped_wave
deactivate Envelope
Melody --> Main : melody_track
deactivate Melody

Main -> Bassline : create_bassline()
activate Bassline
Bassline -> Note : create(frequency, duration)
activate Note
Note --> Bassline : note
deactivate Note
Bassline -> WaveGenerator : generate_square_wave()
activate WaveGenerator
WaveGenerator --> Bassline : wave
deactivate WaveGenerator
Bassline -> Envelope : apply_envelope(wave)
activate Envelope
Envelope --> Bassline : enveloped_wave
deactivate Envelope
Bassline --> Main : bassline_track
deactivate Bassline

Main -> Mixer : add_track(melody_track)
activate Mixer
Main -> Mixer : add_track(bassline_track)
Main -> Mixer : mix()
Mixer --> Main : final_music
deactivate Mixer

Main -> Main : save_as_mp3(final_music)

@enduml
```

## 発展の可能性

このコードをベースに、さまざまな拡張が可能です：

1. 異なるゲームジャンル向けの音楽生成
2. UIを追加して、インタラクティブな音楽作成ツールの開発
3. 機械学習を使用した自動作曲機能の実装
4. リアルタイムでのゲーム状況に応じた動的な音楽生成

## まとめ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2648069/12703128-cd73-e0d8-b6c3-18e2150bc672.png)

Pythonを使って8ビット風のゲーム音楽を生成する方法を紹介しました。明確な要件と仕様に基づいて実装することで、目的に沿った効果的な音楽生成が可能になりました。この手法を使えば、レトロゲーム風の音楽を手軽に作成できます。ぜひ自分好みにアレンジして、オリジナルの8ビットサウンドを作ってみてください！

## 作成した音楽

<iframe width="560" height="315" src="https://www.youtube.com/embed/a2UdHAOQmag?si=tGSAilKZuNxxublB" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>