音源素材ゼロ。numpyのsinとノイズだけで27秒のBGMを作ってみた
Python
numpy
はじめに
宇宙を飛んでいて、カウントダウンのあとにワープして、どこか別の場所に着く。そんなイメージの短い曲が欲しくなりました。
静かに始まって、だんだん高まって、「5、4、3、2、1」で「ドン!」と鳴り、シュワーッとワープして到着する、という流れです。
フリーのBGMを探してもよかったのですが、「ここで盛り上がる」「ここでドン」という秒数まで思ったとおりの曲は、なかなか見つかりません。
それなら、音を全部コードで書いてしまおう、と試してみたら、思ったよりいい感じになりました。
できたのがこちらです。音が出るので注意してください。

コードは Claude に書いてもらい、自分は聴いて「もっと『これから始まる』ワクワクが高まる感じに」と注文を付ける係でした。この記事では、そのコードの中身を1つずつ見ていきます。
全体の流れ
やっていることは、最初から最後までこれだけです。
シンセサイザーのライブラリも、音声処理のライブラリも使いません。配列を作って、足して、掛けるだけです。
音は、ただの数字の列
スピーカーは、渡された数字のとおりに前後に動きます。その数字を1秒に44,100個ならべたものが、音のデータです。
「ラ」の音(440Hz)なら、1秒に440回ゆれるサイン波を計算すれば終わりです。
import numpy as np
SR = 44100 # 1秒あたりの数字の個数
t = np.arange(int(SR * 1.5)) / SR # 0秒〜1.5秒の「時刻」の配列
x = np.sin(2 * np.pi * 440 * t) # 440Hz のサイン波| ことば | 意味 | この記事での値 |
|---|---|---|
| サンプリングレート | 1秒に何個の数字で音を表すか | 44,100個(CDと同じ) |
| 周波数(Hz) | 1秒に何回ゆれるか。大きいほど高い音 | 45Hz〜1万Hzくらい |
| 振幅 | ゆれの大きさ。大きいほど大きな音 | −1〜1 |
ドレミを周波数に変える
曲を書くときに「329.63Hz」のように考えるのは大変です。そこで、鍵盤の番号(MIDIノート番号)から周波数を出す関数を1つ用意します。
def hz(m):
return 440.0 * 2 ** ((m - 69) / 12)69番が440Hzの「ラ」で、番号が1つ上がると半音、12上がると1オクターブ(周波数が2倍)です。
| 番号 | 音 | 周波数 |
|---|---|---|
| 45 | ラ(低い) | 110.0Hz |
| 57 | ラ | 220.0Hz |
| 60 | ド | 261.6Hz |
| 64 | ミ | 329.6Hz |
| 69 | ラ | 440.0Hz |
| 81 | ラ(高い) | 880.0Hz |
これで、和音は [45, 57, 60, 64, 71] のような数字のリストで書けるようになります。
倍音を足して、音色を作る
サイン波だけだと、きれいですが味気ない音です。
楽器の音には、もとの高さの2倍、3倍、4倍……の高さの音(倍音)が少しずつ混ざっています。その混ぜ方が、音色の違いになります。
f = 220
x = (np.sin(2 * np.pi * f * t)
+ 0.5 * np.sin(2 * np.pi * 2 * f * t)
+ 0.25 * np.sin(2 * np.pi * 3 * f * t)
+ 0.12 * np.sin(2 * np.pi * 4 * f * t))今回の曲では、倍音の量を bright という引数にして、曲が進むほど大きくしています。同じ和音でも、後半ほど明るく、前に出てくる音になります。
少しずらして重ねると、厚くなる
もう1つ、音を「それっぽく」する定番があります。同じ音を、高さをほんの少しだけずらして重ねることです。
for det in (-0.004, 0.0, 0.005): # −0.4%、0%、+0.5%
ph = 2 * np.pi * f * (1 + det) * tt + rng.uniform(0, 6.28)
out += np.sin(ph)何人かで同じ音を歌うと、ぴったり同じ高さにはならず、それが合唱らしい厚みになります。それと同じことを計算でやっています。
エンベロープで、楽器の「形」を決める
ここまでの音は、鳴り始めから終わりまで同じ大きさでした。
実際の楽器は、ピアノなら「ポーン」と鳴ってすぐ小さくなり、弦楽器なら「ふわー」と立ち上がります。この音量の変化の形を、エンベロープと呼びます。
作り方はかんたんで、波に「0〜1の間で変化する配列」を掛けるだけです。
def ss(a, b, x):
"""a で 0、b で 1 になる、なめらかな坂"""
u = np.clip((x - a) / (b - a), 0, 1)
return u * u * (3 - 2 * u)
# パッド:1.5秒かけて立ち上がり、2秒かけて消える
env = ss(0, att, tt) * (1 - ss(dur, dur + rel, tt))
# プラック:一瞬で鳴って、すっと消える
env = np.exp(-tt * 6) * ss(0, 0.004, tt)プラックのほうは、倍音にも別々のエンベロープを掛けています。高い倍音ほど早く消えるようにすると、「カーン」と鳴った直後だけ明るく、あとは丸い音が残って、ベルらしくなります。
出だしのほんの少しのフェードイン
プラックの式の最後に付いている ss(0, 0.004, tt) は、4ミリ秒だけのフェードインです。
これがないと、音がいきなり高い位置から始まって、「プチッ」というノイズが乗ります。
ドラムも、sin とノイズでできる
キック
バスドラムの「ドン」は、高い音から低い音へ一瞬ですべり落ちるサイン波です。
def kick(amp=1.0):
n = int(0.45 * SR); tt = np.arange(n) / SR
f = 45 + 110 * np.exp(-tt * 28) # 155Hz → 45Hz
return amp * np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 9)高さが変わる音は、cumsum で作る
キックのコードで大事なのは np.cumsum(f) / SR のところです。
高さが変わらない音なら np.sin(2π * f * t) で作れます。でも、f が時間で変わるときに同じ書き方をすると、ねらった高さになりません。
サイン波の中身(位相)は「これまでに何回ゆれたか」を表しています。高さが変わるなら、その瞬間ごとの周波数を、最初から順に足していく必要があります。それをやってくれるのが np.cumsum (累積和)です。
あとで出てくる「上がっていく音」も、同じ方法で作っています。
スネア
スネアの「タン」は、乱数で作ったホワイトノイズから低い成分を引いたものに、190Hzの短いサイン波を足しています。
def snare(amp=1.0):
n = int(0.16 * SR); tt = np.arange(n) / SR
nz = rng.standard_normal(n); nz = nz - lp(nz, 1800) # 高い成分だけ残す
return amp * (nz * 0.6 * np.exp(-tt * 30)
+ 0.4 * np.sin(2 * np.pi * 190 * tt) * np.exp(-tt * 40))ここで使っている lp は、次に出てくるローパスフィルターです。
盛り上げ役は、ノイズのフタを開けていく
サビ前の「シュワーーー」と上がっていく音(ライザー)は、ホワイトノイズにローパスフィルターを掛けて作ります。
ローパスフィルターは、ある高さより上の音を通さない「フタ」のようなものです。いちばん単純なものは、1行の式を繰り返すだけで書けます。
def lp(x, cutoff):
"""cutoff より高い音を弱める。cutoff は配列でもよい"""
cutoff = np.broadcast_to(np.asarray(cutoff, dtype=float), x.shape)
a = 1 - np.exp(-2 * np.pi * cutoff / SR)
y = np.empty_like(x); s = 0.0
for i in range(len(x)):
s += a[i] * (x[i] - s) # 前の値に、少しだけ近づく
y[i] = s
return y
def noise_sweep(dur, f0, f1, shape=2.0):
n = int(dur * SR); tt = np.arange(n) / SR; u = tt / dur
cut = f0 * (f1 / f0) ** u # フタの位置を f0 → f1 へ
return lp(rng.standard_normal(n), cut) * u ** shapeフタの位置を300Hzから9,000Hzへ少しずつ上げていくと、こもった「ゴー」が、だんだん明るい「シャー」に変わっていきます。
部品を並べて、曲にする
部品がそろったら、あとは「何秒に、何を、どのくらいの音量で置くか」を決めるだけです。
長さ27秒ぶんの空の配列を左右2本用意して、そこに足していきます。
N = int(SR * 27.0)
L = np.zeros(N); R = np.zeros(N)
def add(sig, start, pan=0.0, gain=1.0):
i = int(start * SR)
n = min(len(sig), N - i)
L[i:i+n] += sig[:n] * gain * (1 - max(pan, 0))
R[i:i+n] += sig[:n] * gain * (1 + min(pan, 0))
add(kick(0.9), 14.0, gain=0.55) # 14.0秒にキックを置くpan は左右の位置です。プラックの音を1つずつ左右に振ると、音が頭のまわりを飛び回るように聞こえます。
27秒の設計図
最初に、「19秒でドン!、22秒からワープ、24.45秒で到着」という筋書きを決めました。音は、その秒数に合わせて置いていきます。
| 時間 | 場面 | 鳴っているもの | ねらい |
|---|---|---|---|
| 0〜9秒 | しずか | パッド、まばらなベル | 待っている時間。じゃまをしない |
| 9〜14秒 | 高まる | ベル2倍、ベース、ライザー開始 | 「そろそろかな」 |
| 14〜19秒 | カウントダウン | 毎秒のキックと「ピッ」、加速するスネア | 5、4、3、2、1 |
| 19〜22秒 | ドン! | 低い「ドン」、明るい和音、きらきら | いちばんの山 |
| 22〜24.45秒 | ワープ | ノイズと、70Hzから一気に上がる音 | 場面が切り替わる |
| 24.45〜27秒 | 到着 | 2回目の「ドン」、別の和音 | 新しい場所に着いた |
和音で「空気」を変える
和音の進み方も、数字のリストで書いてあります。
| 秒 | 和音 | 番号のリスト | 感じ |
|---|---|---|---|
| 0 | Am9 | 45, 57, 60, 64, 71 | 少しさびしい、宇宙っぽい |
| 4.5 | F | 41, 53, 57, 60, 67 | ふわっと広がる |
| 9 | C | 48, 55, 60, 64, 67 | 明るくなり始める |
| 11.5 | G | 43, 55, 59, 62, 69 | 前に進む |
| 14 | Am9 → F | (くり返し) | 緊張を保つ |
| 19 | A(メジャー) | 45, 57, 61, 64, 69 … | ぱっと晴れる(ドン!) |
| 24.45 | Dm | 38, 50, 57, 62, 65, 69 | 別の世界に着いた |
ずっとマイナー(暗め)の和音で引っぱっておいて、「ドン!」の瞬間にだけメジャー(明るい)に変えています。60番(ド)を61番(ド♯)に、1つ上げただけです。それだけで、聴いたときの「ぱっと開けた」感じが出ます。
「ドン」の直前に、一瞬だけ無音を入れる
もう1つ効いているのが、「ドン!」の直前の0.1秒ほどの無音です。
gate = 1 - ss(18.84, 18.87, t) * (1 - ss(18.985, 19.0, t))
L *= gate; R *= gate盛り上がりきったところで音を全部止めて、すぐに「ドン」。ジャンプの前に一度しゃがむようなものです。上の波形の図でも、19秒の手前に細い切れ込みが見えます。
できあがりを絵で見る
完成した曲を、スペクトログラム(横が時間、縦が音の高さ)にしたものです。
設計図の表が、そのまま絵になっています。9秒から下のほうが急ににぎやかになり、上のほうにもやが広がって、19秒でぷつっと切れてから縦に1本の線が入ります。
音を聴きながらこの絵を見ると、「いま鳴っているのはこの線か」と分かっておもしろいです。
仕上げ:エコーと tanh
左右に跳ね返るエコー
エコーは、「少し前の自分を、小さくして足す」だけです。配列をずらして足します。
ここでは、左の音を右に、右の音を左に返しています。音が左右に跳ね返るように聞こえるので、ピンポンディレイと呼ばれます。
d = int(0.375 * SR) # 0.375秒 遅らせる
Le = L.copy(); Re = R.copy()
Le[d:] += 0.22 * R[:-d] # 右の音が、遅れて左から
Re[d:] += 0.22 * L[:-d] # 左の音が、遅れて右から
d2 = int(0.75 * SR) # さらに 0.75秒後に、同じ側へ小さく
Le[d2:] += 0.10 * L[:-d2]
Re[d2:] += 0.10 * R[:-d2]0.375秒という数字は、ベルを鳴らす間隔(0.25秒)の1.5倍です。エコーが、もとの音と音の間にちょうど入るので、音の数が増えたように聞こえます。
tanh で音量をまるく収める
たくさんの音を足していくと、合計が1を超えます。そのまま保存すると、はみ出した部分が切り取られて「バリバリ」という音になります。
そこで最後に、全体を np.tanh に通します。
M = np.stack([Le, Re], 1)
M = np.tanh(M * 1.5 / np.max(np.abs(M))) * 0.89小さい音が相対的に持ち上がるので、全体の音が詰まって、聞こえる音量も上がります。
WAV に保存する
保存は、Python に最初から入っている wave モジュールで足ります。−1〜1 の数字を、16ビットの整数(−32767〜32767)に直して書き込みます。
import wave
with wave.open('out.wav', 'wb') as w:
w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR)
w.writeframes((M * 32767).astype('<i2').tobytes())やってみて分かったこと
| 内容 | |
|---|---|
| 👍 秒数がぴったり合う | 「19.0秒にドン」と書けば、19.0秒に鳴る。構成を変えたくなったら、数字を直すだけ |
| 👍 何度でも同じ音が出る | 乱数の種を固定してあるので、実行するたびに同じ曲ができる |
| 👍 直すのが速い | 「ベルをもう少し小さく」は数字を1つ変えて、2秒待つだけ |
| 👍 持ち運びが楽 | 素材ファイルがない。コード1つで曲が再現できる |
| ⚠️ 耳で確かめるしかない | 数字が正しくても、聴いて気持ちいいかは別。何度も聴いて直すことになる |
| ⚠️ 生楽器の音は苦手 | ピアノやギターのような音は、サイン波の足し算だけでは遠い。シンセらしい音に向いている |
| ⚠️ 音量の足し算に注意 | 部品ごとの gain を決めずに足すと、すぐ音が割れる |
特に、位相を cumsum で作ること、出だしに数ミリ秒のフェードインを入れること、最後に tanh を通すこと。この3つは、知らないと「なんだか変な音」のまま原因が分からなくなりそうなところでした。
まとめ
音は、1秒に44,100個ならんだ数字。 np.sin で作れる
倍音を足すと音色、少しずらして重ねると厚み、エンベロープを掛けると楽器らしさが出る
キックは「すべり落ちるサイン波」、スネアやライザーは「フィルターを掛けたノイズ」
曲は、空の配列に「何秒に何を置くか」を足していくだけ
仕上げは、ずらして足すエコーと、tanh
音楽の知識があまりなくても、「数字を1つ変えて、聴いて、また変える」をくり返すうちに、それらしい形になっていくのが楽しいところでした。
「こういう流れの曲が欲しい」というイメージがあるときは、探すかわりにコードで書いてみるのも、おもしろい選択肢だと思います。









