XIAO ESP32S3でドレミファソラシドを鳴らす その6

2026年1月10日土曜日

マイコン工作

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

!注意!
この記事でも、エレキギターっぽい音に到達しなかった。
ただし、記録として公開しておく。


ChatGPTと会話した結果、
---
MX MIDI Guitarを参照するのは「演奏解釈」までに止め、
音は synthio で作り切るのが正解
---
ということになった。

MX MIDI Guitarから参照する「演奏解釈」とは下記の通り。
---
MX MIDI Guitar がやっているのは、**連続するアナログ入力の時間変化から、
note / velocity / timing を確定する演奏解釈だけ**です。
音を鳴らす処理は一切ありません。

具体的には、
* 入力の生値ではなく**差分(変化量)**を見る
* 差分が閾値を超えた瞬間を「ピック候補」とする
* 数 ms〜十数 ms の短い窓で**ピーク値**を取り、ノイズか有効かを判定
* ピーク値を正規化して **velocity** を決定
* ごく短時間に起きた複数弦の反応を**同一ストラム**として束ねる
* 弦番号と押弦情報から **note** を確定し、そこで初めて `note_on` を出す
* 入力が弱まったら、同様に閾値判定で `note_off` を出す

重要なのは、
* 音色・波形・歪み・エンベロープは扱わない
* 「ギターっぽさ」は**アタック直後の検出精度とタイミング解釈**で作っている
* 出力は常に **演奏イベント(note, velocity, timing)だけ**

あなたの構成では、
MX MIDI Guitar の `note_on(note, velocity)` を
`synthio` の `play_note(freq, velocity)` に置き換えるだけで、
**演奏解釈ロジックはそのまま前段として使えます**。

これが、MX MIDI Guitar の演奏解釈をコード準拠で言語化した最小要約です。
---

下記のコードで、MX MIDI Guitarの「演奏解釈」のうち ピック検出と強さまでは
再現できたようだが、エレキギターっぽい音じゃない。

///
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

# --- Interpretation layer (MX MIDI Guitar style, no sound) ---
PICK_DIFF_THRESHOLD = 0.25
PICK_WINDOW_MS = 12
PEAK_MIN = 0.30
PEAK_MAX = 1.00
STRUM_HOLD_MS = 60
OFF_HOLD_MS = 80
SIGNAL_DT_MS = 1
SIGNAL_LEN_MS = 2600

# Dummy notes (C D E F G A B C)
NOTE_SEQUENCE = [261.63, 293.66, 329.63, 349.23, 392.00, 440.00, 493.88, 523.25]

# --- Sound layer (synthio only) ---
AMP_MIN = 0.05
AMP_MAX = 0.15
ATTACK = 0.002
DECAY = 0.12
SUSTAIN = 0.10
RELEASE = 0.08
MIN_NOTE_ON_SEC = 0.25

DRIVE_BASE = 0.6
ODD3_BASE = 1.0
ODD5_BASE = 0.25
PHASE_WARP_BASE = 0.00

UPDATE_STEP = 0.006
ATTACK_WAVE_MS = 12
ATTACK_PITCH_MS = 20
PITCH_CENTS = 3.0

LP_FC_NORMAL = 4500.0
SAT_DRIVE = 0.6
POST_GAIN = 1.8

WAVE_LEN = 128
MAX_I16 = 32767
NOTE_GAP_SEC = 0.2

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,
)

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 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
   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)
       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)
       z = lp_y * POST_GAIN
       if z > 1.0:
           z = 1.0
       elif z < -1.0:
           z = -1.0
       w[i] = int(z * MAX_I16)
   return w

# -----------------------------
# Interpretation layer
# -----------------------------
interp_last_value = 0.0
interp_last_time = 0.0
interp_state = "idle"
interp_peak_max = 0.0
interp_peak_end = 0.0
interp_hold_until = 0.0
interp_below_ms = 0.0
interp_current_note = None
interp_note_index = 0
interp_next_pick_time = 0.0

def interpret_step(now, note_sequence):
   global interp_last_value, interp_last_time, interp_state
   global interp_peak_max, interp_peak_end, interp_hold_until
   global interp_below_ms, interp_current_note, interp_note_index
   global interp_next_pick_time

   if interp_last_time == 0.0:
       interp_last_time = now
       interp_next_pick_time = now + 0.2
       return None

   dt = now - interp_last_time
   if dt <= 0:
       return None
   interp_last_time = now

   noise = (rand_unit() - 0.5) * 0.02
   spike = 0.0
   if now >= interp_next_pick_time:
       spike = 0.6
       interp_next_pick_time = now + 0.25 + (rand_unit() * 0.05)

   value = (interp_last_value * 0.98) + noise + spike
   diff = abs(value - interp_last_value)
   interp_last_value = value

   if interp_state == "idle":
       if now < interp_hold_until:
           return None
       if diff > PICK_DIFF_THRESHOLD:
           interp_state = "peak"
           interp_peak_max = diff
           interp_peak_end = now + (PICK_WINDOW_MS / 1000.0)
       return None

   if interp_state == "peak":
       if diff > interp_peak_max:
           interp_peak_max = diff
       if now >= interp_peak_end:
           if interp_peak_max < PEAK_MIN:
               interp_state = "idle"
               return None
           velocity = clamp01((interp_peak_max - PEAK_MIN) / (PEAK_MAX - PEAK_MIN))
           interp_current_note = note_sequence[interp_note_index % len(note_sequence)]
           interp_note_index += 1
           interp_hold_until = now + (STRUM_HOLD_MS / 1000.0)
           interp_state = "wait_reset"
           interp_below_ms = 0.0
           return ("on", interp_current_note, velocity, now)
       return None

   if interp_state == "wait_reset":
       if diff < PICK_DIFF_THRESHOLD:
           interp_below_ms += dt * 1000.0
           if interp_below_ms >= OFF_HOLD_MS:
               interp_state = "idle"
               return ("off", interp_current_note, 0.0, now)
       else:
           interp_below_ms = 0.0
       return None

# -----------------------------
# Sound layer
# -----------------------------
WAVEFORM = make_wave(DRIVE_BASE, ODD3_BASE, ODD5_BASE, PHASE_WARP_BASE, LP_FC_NORMAL)

def play_event(active, freq, velocity, start_time):
   amp = AMP_MIN + (AMP_MAX - AMP_MIN) * velocity
   drive = DRIVE_BASE + (0.6 * velocity)
   wave_normal = make_wave(drive, ODD3_BASE, ODD5_BASE, PHASE_WARP_BASE, LP_FC_NORMAL)
   wave_attack = make_wave(drive * 1.4, ODD3_BASE * 1.3, ODD5_BASE * 1.2, PHASE_WARP_BASE, LP_FC_NORMAL)
   note = synthio.Note(frequency=freq, envelope=env, amplitude=amp, waveform=wave_attack)
   synth.press(note)
   active[freq] = {
       "note": note,
       "start": start_time,
       "amp": amp,
       "freq": freq,
       "wave_normal": wave_normal,
       "wave_attack": wave_attack,
       "release_at": None,
   }

def update_active(active, now):
   to_release = []
   for freq, state in active.items():
       note = state["note"]
       t = now - state["start"]
       if t < (ATTACK_WAVE_MS / 1000.0):
           note.waveform = state["wave_attack"]
       else:
           note.waveform = state["wave_normal"]
       if t < (ATTACK_PITCH_MS / 1000.0):
           decay = math.exp(-t / 0.01)
           note.frequency = state["freq"] * cents_to_ratio(PITCH_CENTS * decay)
       else:
           note.frequency = state["freq"]
       release_at = state["release_at"]
       if release_at is not None and now >= release_at:
           to_release.append(freq)
   for freq in to_release:
       synth.release(active[freq]["note"])
       del active[freq]

active_notes = {}
while True:
   now = time.monotonic()
   event = interpret_step(now, NOTE_SEQUENCE)
   if event:
       ev_type, freq, velocity, _t = event
       if ev_type == "on":
           if freq in active_notes:
               synth.release(active_notes[freq]["note"])
               del active_notes[freq]
           play_event(active_notes, freq, velocity, now)
       else:
           if freq in active_notes:
               start_time = active_notes[freq]["start"]
               hold_until = start_time + MIN_NOTE_ON_SEC
               if now >= hold_until:
                   synth.release(active_notes[freq]["note"])
                   del active_notes[freq]
               else:
                   active_notes[freq]["release_at"] = hold_until
   update_active(active_notes, now)
   time.sleep(UPDATE_STEP)
///

で、何時間もかけて、
---
エレキギターっぽさに近づく作業ではありましたが、そのままでは“本物っぽい
音色”には到達しにくい作業でした。理由は、いまやっているのが主に MX MIDI
Guitar の「演奏解釈(いつ・どれだけ強く弾いたか)」の再現で、音色の核
(ピックノイズ、高域の瞬間成分、弦ごとの倍音減衰、アンプ/スピーカーの癖)
は別レイヤーです。
---
という結論になってしまった。

次のアクションは、
エレキギターっぽい音を目指し、発音側を変更する。
・ピック直後5〜15msだけノイズ成分を重ねる
・15ms後に「明るい波形→暗い波形」に切り替える
・ドレミファソラシドで鳴らして、ノイズなし版と聴き比べる


このブログを検索

ブログ アーカイブ

XIAO ESP32S3でドレミファソラシドを鳴らす その6

https://www.notyet-maker.com/2026/01/xiao-esp32s3-5.html の続き。 !注意! この記事でも、エレキギターっぽい音に到達しなかった。 ただし、記録として公開しておく。 ChatGPTと会話した結果、 --- MX MIDI G...

QooQ