の続き。
これまで、ChatGPTに質問しながら、XIAO ESP32S3でエレキギターっぽい音を出そうとしていたが、一向に進まなかった。
Geminiに質問したところ、ESP Audio Effectsというものがあることを知る。
またもやうまく進まないのではないか、と心配だけど、ChatGPTに質問しながら、
CircuitPythonでESP Audio Effectsの設計思想を流用する、ということについてトライしてみる。
具体的には、CircuitPythonで、
発音:synthioで基音だけを作る
Fader:amplitudeやゲインを時間で動かす
Mixer:audiomixerでノイズ層と基音層を混ぜる
Sonic:audiodelaysで微小ディレイや揺れを足す
という対応に置き換える、という。
しばらく、ChatGPTとやり取りしていたが、解決しなさそうなので、
Geminiの方が優れているのか質問したら、非を認めてしまった。
今回は、Gemini3を利用することにする。
ノイジーだけど、Smoke on the Waterっぽい音を作ることができた。
///
import math
import time
from array import array
import audiobusio
import board
import synthio
# --- Hardware / runtime ---
SAMPLE_RATE = 18000
I2S_BCLK = board.D6
I2S_LRCLK = board.D3
I2S_DATA = board.D7
# --- Sequencer (Smoke on the Water riff, 16th-note timing) ---
BEAT_SEC = 0.134
G2 = 98.00
BB2 = 116.54
C3 = 130.81
DB3 = 138.59
NOTE_SEQUENCE_ABSOLUTE = [
(0, G2, 2),
(3, BB2, 2),
(5, C3, 7),
(16, G2, 2),
(19, BB2, 2),
(21, DB3, 2),
(23, C3, 6),
(32, G2, 2),
(35, BB2, 2),
(37, C3, 2),
(40, BB2, 2),
(42, G2, 10),
]
LOOP_STEPS = 48
RELEASE_TAIL_SEC = 0.05
NOTE_VELOCITY = 0.8
# --- Sound layer (synthio) ---
AMP_MIN = 0.06
AMP_MAX = 0.08
ATTACK = 0.003
DECAY = 0.40
SUSTAIN = 0.85
RELEASE = 0.12
UPDATE_STEP = 0.01
ATTACK_WAVE_MS = 12
ATTACK_DECAY_MS = 40
ATTACK_PITCH_MS = 18
PITCH_CENTS = 2.8
LP_FC_NORMAL = 4800.0
LP_FC_DAMP = 1500.0
SAT_DRIVE = 0.6
POST_GAIN = 0.78
CAB_FC = 4200.0
CAB_CUT = 0.55
CAB_MID_DRIVE = 0.30
WAVE_LEN = 128
MAX_I16 = 32767
SUSTAIN_AMP_BOOST = 2.20
AMP_SOFT_CLIP = 0.10
SUSTAIN_BRIGHT_MIX = 0.36
SUSTAIN_DARK_MIX = 0.12
SUSTAIN_WOBBLE_MAX_CENTS = 0.06
SUSTAIN_WOBBLE_STEP = 0.02
SUSTAIN_WOBBLE_DELAY_MS = 60
SUSTAIN_WOBBLE_PROB = 0.10
VEL_BINS = (0.35, 0.65, 1.0)
HARMONIC_PROFILE_GAIN = 0.28
HARMONIC_PROFILE = (
(2, -0.22),
(3, 0.30),
(4, -0.18),
(5, 0.20),
(6, -0.12),
)
DRIVE_BASE = 0.5
ODD3_BASE = 0.65
ODD5_BASE = 0.18
PHASE_WARP_BASE = 0.00
# --- Noise burst layer (synthio note) ---
NOISE_WAVE_LEN = 256
NOISE_BURST_MIN_MS = 4
NOISE_BURST_MAX_MS = 8
NOISE_LEVEL_MAX = 0.08
rng = 1
def rand_unit():
global rng
rng = (rng * 1103515245 + 12345) & 0x7FFFFFFF
return rng / 0x7FFFFFFF
def clamp01(value):
if value < 0.0:
return 0.0
if value > 1.0:
return 1.0
return value
def cents_to_ratio(cents):
return 2 ** (cents / 1200.0)
def alpha_from_fc(fc):
dt = 1.0 / SAMPLE_RATE
rc = 1.0 / (2.0 * math.pi * fc)
return dt / (rc + dt)
def soft_sat(x, drive):
y = x + drive * (x * x * x)
if y > 1.0:
y = 1.0
elif y < -1.0:
y = -1.0
return y
def clamp_i16(value):
if value > MAX_I16:
return MAX_I16
if value < -MAX_I16:
return -MAX_I16
return value
def soft_clip_amp(value):
if value <= AMP_SOFT_CLIP:
return value
excess = value - AMP_SOFT_CLIP
return AMP_SOFT_CLIP + (excess / (1.0 + (excess * 8.0)))
def mix_waves(wave_a, wave_b, mix):
w = array("h", [0] * WAVE_LEN)
a = 1.0 - mix
b = mix
for i in range(WAVE_LEN):
w[i] = clamp_i16(int((wave_a[i] * a) + (wave_b[i] * b)))
return w
def vel_key(value):
best = VEL_BINS[0]
best_dist = abs(value - best)
for level in VEL_BINS[1:]:
dist = abs(value - level)
if dist < best_dist:
best = level
best_dist = dist
return best
WAVE_CACHE = {}
def get_waves(velocity):
key = vel_key(velocity)
waves = WAVE_CACHE.get(key)
if waves is not None:
return waves
drive = DRIVE_BASE + (0.5 * key)
wave_normal = make_wave(drive, ODD3_BASE, ODD5_BASE, PHASE_WARP_BASE, LP_FC_NORMAL)
wave_attack = make_wave(
drive * 1.3,
ODD3_BASE * 1.25,
ODD5_BASE * 1.15,
PHASE_WARP_BASE,
LP_FC_NORMAL,
)
wave_damp = make_wave(drive, ODD3_BASE, ODD5_BASE, PHASE_WARP_BASE, LP_FC_DAMP)
wave_mid = mix_waves(wave_normal, wave_attack, SUSTAIN_BRIGHT_MIX)
wave_sustain = mix_waves(wave_normal, wave_attack, SUSTAIN_DARK_MIX)
waves = (wave_normal, wave_attack, wave_mid, wave_sustain, wave_damp)
WAVE_CACHE[key] = waves
return waves
def make_wave(drive, odd3, odd5, phase_warp, lp_fc):
w = array("h", [0] * WAVE_LEN)
lp_alpha = alpha_from_fc(lp_fc)
lp_y = 0.0
cab_alpha = alpha_from_fc(CAB_FC)
cab_y = 0.0
for i in range(WAVE_LEN):
phase = 2.0 * math.pi * i / WAVE_LEN
phase = phase + phase_warp * math.sin(phase)
x = math.sin(phase)
for harm, weight in HARMONIC_PROFILE:
x += HARMONIC_PROFILE_GAIN * weight * math.sin(harm * phase)
x = x + (odd3 * drive) * (x * x * x) + (odd5 * drive) * (x * x * x * x * x)
x = soft_sat(x, SAT_DRIVE)
lp_y = lp_y + lp_alpha * (x - lp_y)
cab_y = cab_y + cab_alpha * (lp_y - cab_y)
shaped = (lp_y * (1.0 - CAB_CUT)) + (cab_y * CAB_CUT)
shaped = soft_sat(shaped, CAB_MID_DRIVE)
z = shaped * POST_GAIN
if z > 1.0:
z = 1.0
elif z < -1.0:
z = -1.0
w[i] = int(z * MAX_I16)
return w
def build_noise_wave():
data = array("h", [0] * NOISE_WAVE_LEN)
for i in range(NOISE_WAVE_LEN):
n = (rand_unit() * 2.0) - 1.0
data[i] = clamp_i16(int(n * MAX_I16))
return data
i2s = audiobusio.I2SOut(I2S_BCLK, I2S_LRCLK, I2S_DATA)
synth = synthio.Synthesizer(sample_rate=SAMPLE_RATE)
i2s.play(synth)
env = synthio.Envelope(
attack_time=ATTACK,
decay_time=DECAY,
sustain_level=SUSTAIN,
release_time=RELEASE,
)
noise_wave = build_noise_wave()
noise_env = synthio.Envelope(
attack_time=0.001,
decay_time=0.006,
sustain_level=0.0,
release_time=0.008,
)
noise_state = None
def trigger_noise_burst(now):
global noise_state
burst_ms = NOISE_BURST_MIN_MS + rand_unit() * (NOISE_BURST_MAX_MS - NOISE_BURST_MIN_MS)
noise_note = synthio.Note(
frequency=440.0,
envelope=noise_env,
amplitude=NOISE_LEVEL_MAX,
waveform=noise_wave,
)
synth.press(noise_note)
noise_state = {
"start": now,
"end": now + (burst_ms / 1000.0),
"note": noise_note,
}
def update_noise(now):
global noise_state
if noise_state is None:
return
if now >= noise_state["end"]:
synth.release(noise_state["note"])
noise_state = None
return
def fader_gain(base_amp, t_ms):
if t_ms < ATTACK_WAVE_MS:
return min(base_amp * 1.15, AMP_MAX)
if t_ms < ATTACK_DECAY_MS:
blend = (t_ms - ATTACK_WAVE_MS) / (ATTACK_DECAY_MS - ATTACK_WAVE_MS)
return base_amp * (1.15 - (0.15 * blend))
return soft_clip_amp(base_amp * SUSTAIN_AMP_BOOST)
def play_note(freq, velocity, now):
base_amp = AMP_MIN + (AMP_MAX - AMP_MIN) * velocity
wave_normal, wave_attack, wave_mid, wave_sustain, wave_damp = get_waves(velocity)
freqs = (freq, freq * 1.3348)
states = []
for voice_freq in freqs:
note = synthio.Note(
frequency=voice_freq, envelope=env, amplitude=base_amp, waveform=wave_attack
)
synth.press(note)
states.append(
{
"note": note,
"start": now,
"amp": base_amp,
"freq": voice_freq,
"wave_attack": wave_attack,
"wave_mid": wave_mid,
"wave_sustain": wave_sustain,
"wave_damp": wave_damp,
"wobble_value": (rand_unit() - 0.5) * SUSTAIN_WOBBLE_MAX_CENTS * 0.5,
}
)
return states
def update_note(state, now):
note = state["note"]
t = now - state["start"]
t_ms = t * 1000.0
if t < (ATTACK_WAVE_MS / 1000.0):
note.waveform = state["wave_attack"]
elif t_ms < ATTACK_DECAY_MS:
note.waveform = state["wave_mid"]
elif t_ms < 800.0:
note.waveform = state["wave_sustain"]
else:
note.waveform = state["wave_damp"]
note.amplitude = fader_gain(state["amp"], t_ms)
if t < (ATTACK_PITCH_MS / 1000.0):
decay = math.exp(-t / 0.01)
note.frequency = state["freq"] * cents_to_ratio(PITCH_CENTS * decay)
else:
if t >= (SUSTAIN_WOBBLE_DELAY_MS / 1000.0):
if rand_unit() < SUSTAIN_WOBBLE_PROB:
wobble_value = state["wobble_value"] + (
(rand_unit() - 0.5) * 2.0 * SUSTAIN_WOBBLE_STEP
)
if wobble_value > SUSTAIN_WOBBLE_MAX_CENTS:
wobble_value = SUSTAIN_WOBBLE_MAX_CENTS
elif wobble_value < -SUSTAIN_WOBBLE_MAX_CENTS:
wobble_value = -SUSTAIN_WOBBLE_MAX_CENTS
state["wobble_value"] = wobble_value
note.frequency = state["freq"] * cents_to_ratio(state["wobble_value"])
else:
note.frequency = state["freq"]
current_notes = None
event_index = 0
loop_offset_steps = 0
start_time = time.monotonic() + 0.2
next_event_time = start_time + (NOTE_SEQUENCE_ABSOLUTE[0][0] * BEAT_SEC)
note_off_time = 0.0
current_hold = 0.0
while True:
now = time.monotonic()
if current_notes is None and now >= next_event_time:
selected = None
while now >= next_event_time:
start_step, freq, hold_units = NOTE_SEQUENCE_ABSOLUTE[event_index]
hold_sec = max(0.0, (BEAT_SEC * hold_units) - RELEASE_TAIL_SEC)
selected = (freq, hold_sec)
event_index += 1
if event_index >= len(NOTE_SEQUENCE_ABSOLUTE):
event_index = 0
loop_offset_steps += LOOP_STEPS
next_event_time = start_time + (
(loop_offset_steps + NOTE_SEQUENCE_ABSOLUTE[event_index][0]) * BEAT_SEC
)
if selected is not None:
freq, hold_sec = selected
current_notes = play_note(freq, NOTE_VELOCITY, now)
current_hold = hold_sec
note_off_time = now + hold_sec
trigger_noise_burst(now)
elif current_notes is not None and now >= note_off_time:
for state in current_notes:
synth.release(state["note"])
current_notes = None
if current_notes is not None:
for state in current_notes:
update_note(state, now)
update_noise(now)
time.sleep(UPDATE_STEP)
///
コードの説明文は、ChatGPTの方が良かった。
---
全体としては「Smoke on the Water を16分刻みの“絶対時刻”で鳴らしつつ、ギターっぽい発音を synthio の波形切替+揺れ+ノイズで作る」コードです。
音を出す土台
audiobusio.I2SOut(D6,D3,D7) で I2S 出力し、synthio.Synthesizer(sample_rate=18000) を i2s.play(synth) で常時駆動しています。
リフ(リズムと音程)の指定方法 NOTE_SEQUENCE_ABSOLUTE は「開始ステップ・周波数・保持ステップ数」を持つ配列で、BEAT_SEC=0.134 を1ステップとして扱います。 start_time + (loop_offset_steps + start_step)*BEAT_SEC により絶対時刻で再生するため、処理遅延があってもテンポが崩れにくい設計です。
“ギターっぽさ”の作り方(音色)
make_wave() で倍音・歪み・サチュレーション・簡易キャビネット特性を含む周期波形を生成し、get_waves() でアタック/中間/サステイン/減衰後用の波形を用意します。 再生中は update_note() が経過時間に応じて note.waveform を切り替え、明るさが徐々に落ちる挙動を作っています。
発音の“それっぽさ”(挙動)
fader_gain() でアタック直後を強調しつつ伸びを作り、ATTACK_PITCH_MS の間だけピッチをずらしてピック直後の張力変化を表現します。 サステイン中は確率的な微小ピッチ揺れで、機械的な音を避けています。
2音同時(このリフの太さ)
freqs = (freq, freq * 1.3348) により基音+完全4度付近の2音を同時発音し、Smoke on the Water 特有のダブルストップ感を出しています。
ノイズバースト(ピックのアタック)
ノート開始時に短いノイズを重ね、ピックの瞬間的な成分を補っています。
メインループ(スケジューラ)
while now >= next_event_time で遅れを吸収しつつイベントを選択し、note_off_time で離鍵、再生中は update_note() を毎ループ適用します。
---
0 件のコメント:
コメントを投稿