XIAO ESP32S3でESP Audio Effectsを流用してみる

2026年1月19日月曜日

マイコン工作

https://www.notyet-maker.com/2026/01/xiao-esp32s3-7.html
の続き。

これまで、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() を毎ループ適用します。
---



このブログを検索

ブログ アーカイブ

XIAO ESP32S3でESP Audio Effectsを流用してみる

https://www.notyet-maker.com/2026/01/xiao-esp32s3-7.html の続き。 これまで、ChatGPTに質問しながら、XIAO ESP32S3でエレキギターっぽい音を出そうとしていたが、一向に進まなかった。 Geminiに質問したとこ...

QooQ