# コード名: py_synth_v60_arpeggiator
# バージョン: 60.0 (Chord Arpeggiator)
# Description: Page 8にアルペジエーター機能(BPM/Pattern)を追加。
import os
os.environ['OMP_NUM_THREADS'] = '1'
import sys
import time
import threading
import math
import subprocess
import random
import numpy as np
import rtmidi
import fluidsynth
import digitalio
import board
import mido
from PIL import Image, ImageDraw, ImageFont
from adafruit_rgb_display import st7789
# --- ライブラリチェック ---
try:
import sounddevice as sd
except ImportError:
sys.exit("Error: pip install sounddevice")
try:
from scipy.signal import lfilter
except ImportError:
sys.exit("Error: pip install scipy")
# --- 設定 ---
TARGET_DEVICE_KEYWORD = "USB"
SAMPLE_RATE = 48000
BLOCK_SIZE = 256
CHANNELS = 2
DTYPE = 'int16'
MASTER_GAIN_DEFAULT = 0.5
SF2_PATH = "/usr/share/sounds/sf2/FluidR3_GM.sf2"
MIDI_DIR = "./midi"
# LCD Pins
CS_PIN = board.D8
DC_PIN = board.D27
RST_PIN = board.D17
BAUDRATE = 24000000
is_running = True
last_audio_data = np.zeros(BLOCK_SIZE, dtype=np.int16)
midi_logs = []
last_drum_name = ""
# --- コード定義 ---
CHORD_TYPES = [
{"name": "OFF", "offsets": [0]},
{"name": "Major", "offsets": [0, 4, 7]},
{"name": "Minor", "offsets": [0, 3, 7]},
{"name": "Maj7", "offsets": [0, 4, 7, 11]},
{"name": "Min7", "offsets": [0, 3, 7, 10]},
{"name": "Sus4", "offsets": [0, 5, 7]},
{"name": "5th(Pwr)", "offsets": [0, 7]},
{"name": "Octave", "offsets": [0, 12]}
]
# --- アルペジオパターン ---
ARP_PATTERNS = ["OFF", "UP", "DOWN", "RAND"]
# --- GM楽器名 ---
GM_INSTRUMENTS = [
"Grand Piano", "Bright Piano", "E.Grand Piano", "Honky-tonk", "E.Piano 1", "E.Piano 2", "Harpsichord", "Clavinet",
"Celesta", "Glockenspiel", "Music Box", "Vibraphone", "Marimba", "Xylophone", "Tubular Bells", "Dulcimer",
"Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", "Reed Organ", "Accordion", "Harmonica", "Tango Accordion",
"Guit(Nylon)", "Guit(Steel)", "Guit(Jazz)", "Guit(Clean)", "Guit(Muted)", "Guit(Overdrive)", "Guit(Distortion)", "Guit(Harmonics)",
"Acoustic Bass", "E.Bass(Finger)", "E.Bass(Pick)", "Fretless Bass", "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2",
"Violin", "Viola", "Cello", "Contrabass", "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani",
"Strings 1", "Strings 2", "SynthStrings 1", "SynthStrings 2", "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit",
"Trumpet", "Trombone", "Tuba", "Muted Trumpet", "French Horn", "Brass Section", "Synth Brass 1", "Synth Brass 2",
"Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", "Oboe", "English Horn", "Bassoon", "Clarinet",
"Piccolo", "Flute", "Recorder", "Pan Flute", "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina",
"Lead 1 (square)", "Lead 2 (saw)", "Lead 3 (calliope)", "Lead 4 (chiff)", "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass+lead)",
"Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)",
"FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)",
"Sitar", "Banjo", "Shamisen", "Koto", "Kalimba", "Bag pipe", "Fiddle", "Shanai",
"Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal",
"Guit Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", "Telephone Ring", "Helicopter", "Applause", "Gunshot"
]
# --- ドラムマップ ---
DRUM_MAP = {
36: "Bass Drum 1", 37: "Side Stick", 38: "Acoustic Snare", 39: "Hand Clap",
40: "Electric Snare", 41: "Low Floor Tom", 42: "Closed Hi-Hat", 43: "High Floor Tom",
44: "Pedal Hi-Hat", 45: "Low Tom", 46: "Open Hi-Hat", 47: "Low-Mid Tom",
48: "Hi-Mid Tom", 49: "Crash Cymbal 1", 50: "High Tom", 51: "Ride Cymbal 1"
}
class SynthParams:
def __init__(self):
self.engine_mode = "ANALOG"
self.master_gain = MASTER_GAIN_DEFAULT
self.waveform = "sawtooth"
self.lfo_rate = 5.0; self.lfo_depth = 0.0
self.filter_mode = "LPF"; self.cutoff_base = 1000.0; self.resonance = 0.5; self.vcf_env_amt = 4000.0
self.vcf_attack = 0.1; self.vcf_decay = 0.3; self.vcf_sustain = 0.4; self.vcf_release = 0.5
self.amp_attack = 0.05; self.amp_decay = 0.2; self.amp_sustain = 0.6; self.amp_release = 0.5
self.sf_program = 0; self.sf_vol = 1.0
self.reverb_send = 40
self.chorus_send = 0
# Chord & Arp
self.chord_type_idx = 0
self.arp_pattern_idx = 0 # 0=OFF
self.arp_bpm = 120.0
self.page_index = 0
self.gate_status = False
params = SynthParams()
# --- Helper Functions for System Info ---
def get_ip_address():
try:
cmd = "hostname -I | cut -d' ' -f1"
return subprocess.check_output(cmd, shell=True).decode("utf-8").strip()
except: return "Unknown"
def get_cpu_temp():
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
temp = float(f.read()) / 1000.0
return temp
except: return 0.0
def get_disk_usage():
try:
cmd = "df -h / | awk 'NR==2{print $3 \"/\" $2 \" (\" $5 \")\"}'"
return subprocess.check_output(cmd, shell=True).decode("utf-8").strip()
except: return "?"
def get_ram_usage():
try:
cmd = "free -m | awk 'NR==2{printf \"%s/%sMB (%.0f%%)\", $3,$2,$3*100/$2 }'"
return subprocess.check_output(cmd, shell=True).decode("utf-8").strip()
except: return "?"
# --- MIDI Player Class ---
class MidiPlayer:
def __init__(self):
self.files = []
self.selected_idx = 0
self.is_playing = False
self.stop_signal = False
self.thread = None
self.scan_files()
def scan_files(self):
self.files = []
if os.path.exists(MIDI_DIR):
for f in os.listdir(MIDI_DIR):
if f.lower().endswith(".mid"):
self.files.append(f)
self.files.sort()
else:
try:
os.makedirs(MIDI_DIR)
print(f"Created {MIDI_DIR}")
except: pass
if not self.files:
self.files = ["(No Files)"]
def select_file(self, norm_val):
count = len(self.files)
self.selected_idx = min(count - 1, int(norm_val * count))
def play(self):
if self.is_playing: return
if self.files[0] == "(No Files)": return
self.stop_signal = False
self.is_playing = True
self.thread = threading.Thread(target=self._play_thread, daemon=True)
self.thread.start()
def stop(self):
if self.is_playing:
self.stop_signal = True
for ch in range(16):
process_midi_event(0xB0 + ch, 120, 0)
process_midi_event(0xB0 + ch, 123, 0)
def _play_thread(self):
filename = os.path.join(MIDI_DIR, self.files[self.selected_idx])
try:
mid = mido.MidiFile(filename)
print(f"Playing: {filename}")
if params.engine_mode == "ANALOG":
params.engine_mode = "DIGITAL1"
for msg in mid.play():
if self.stop_signal or not is_running: break
if msg.type == 'note_on': process_midi_event(0x90 | msg.channel, msg.note, msg.velocity)
elif msg.type == 'note_off': process_midi_event(0x80 | msg.channel, msg.note, msg.velocity)
elif msg.type == 'control_change': process_midi_event(0xB0 | msg.channel, msg.control, msg.value)
elif msg.type == 'program_change': process_midi_event(0xC0 | msg.channel, msg.program, 0)
print("Playback Finished.")
except Exception as e:
print(f"MIDI Error: {e}")
self.is_playing = False
self.stop_signal = False
midi_player = MidiPlayer()
class FluidManager:
def __init__(self):
self.fs = None; self.ready = False
def init_synth(self):
try:
self.fs = fluidsynth.Synth()
self.fs.setting('synth.sample-rate', float(SAMPLE_RATE))
self.fs.setting('synth.gain', 1.0)
self.fs.setting('synth.polyphony', 64)
self.fs.setting('synth.reverb.active', 'yes')
self.fs.setting('synth.chorus.active', 'yes')
sfid = self.fs.sfload(SF2_PATH)
self.fs.program_select(0, sfid, 0, 0)
res = self.fs.program_select(9, sfid, 128, 0)
if res != 0: self.fs.program_select(9, sfid, 120, 0)
self.fs.program_change(9, 0)
self.fs.cc(0, 91, params.reverb_send)
self.fs.cc(0, 93, params.chorus_send)
self.ready = True
print("FluidSynth Ready (Reverb/Chorus ON).")
except Exception as e:
print(f"FluidSynth Error: {e}"); self.ready = False
def get_samples(self, n):
if not self.ready: return np.zeros(n * 2, dtype=np.int16)
return np.array(self.fs.get_samples(n), dtype=np.int16)
def note_on(self, ch, n, v):
if self.ready: self.fs.noteon(ch, n, v)
def note_off(self, ch, n):
if self.ready: self.fs.noteoff(ch, n)
def change_program(self, ch, p):
if self.ready: self.fs.program_change(ch, int(p))
def send_cc(self, ch, cc, val):
if self.ready: self.fs.cc(ch, cc, int(val))
fluid_engine = FluidManager()
# --- Arpeggiator ---
class Arpeggiator:
def __init__(self):
self.current_notes = [] # [(note, vel)]
self.playing_note = None
self.step_idx = 0
self.last_step_time = 0
threading.Thread(target=self._loop, daemon=True).start()
def set_notes(self, notes_with_vel):
# notes_with_vel: list of (n, v)
self.current_notes = notes_with_vel
self.current_notes.sort(key=lambda x: x[0]) # Default sort UP
def _loop(self):
while is_running:
mode = ARP_PATTERNS[params.arp_pattern_idx]
# ARP OFF: Do nothing (Logic handled in process_midi)
if mode == "OFF" or not self.current_notes:
if self.playing_note is not None:
fluid_engine.note_off(0, self.playing_note)
self.playing_note = None
time.sleep(0.01)
continue
# ARP ON
step_duration = 60.0 / params.arp_bpm / 2.0 # 8th notes
now = time.time()
if now - self.last_step_time >= step_duration:
# Stop prev
if self.playing_note is not None:
fluid_engine.note_off(0, self.playing_note)
# Select Next Note
notes = self.current_notes[:] # Copy
if not notes: continue
if mode == "UP":
pass # Sorted
elif mode == "DOWN":
notes.reverse()
elif mode == "RAND":
random.shuffle(notes)
# Safe idx
self.step_idx %= len(notes)
n, v = notes[self.step_idx]
fluid_engine.note_on(0, n, v)
self.playing_note = n
if mode == "RAND":
self.step_idx = random.randint(0, len(notes)-1)
else:
self.step_idx += 1
self.last_step_time = now
time.sleep(0.005)
arp = Arpeggiator()
def design_biquad(mode, fc, q, fs):
w0=2*math.pi*fc/fs; alpha=math.sin(w0)/(2*q); c=math.cos(w0); a0=1+alpha
if mode=="LPF": b=[(1-c)/2, 1-c, (1-c)/2]
elif mode=="HPF": b=[(1+c)/2, -(1+c), (1+c)/2]
elif mode=="BPF": b=[alpha, 0, -alpha]
else: return [1,0,0],[1,0,0]
return np.array(b)/a0, np.array([1+alpha,-2*c,1-alpha])/a0
class ADSREnvelope:
def __init__(self, is_amp=True):
self.state='IDLE'; self.level=0.0; self.is_amp=is_amp
def trigger(self): self.state='ATTACK'
def release(self): self.state='RELEASE'
def get_val(self):
if self.is_amp: a,d,s,r = params.amp_attack,params.amp_decay,params.amp_sustain,params.amp_release
else: a,d,s,r = params.vcf_attack,params.vcf_decay,params.vcf_sustain,params.vcf_release
step = float(BLOCK_SIZE)/SAMPLE_RATE
if self.state=='ATTACK':
self.level+=step/max(a,0.001);
if self.level>=1.0: self.level,self.state=1.0,'DECAY'
elif self.state=='DECAY':
self.level-=(step/max(d,0.001))*(1.0-s);
if self.level<=s: self.level,self.state=s,'SUSTAIN'
elif self.state=='SUSTAIN': self.level=s
elif self.state=='RELEASE':
self.level-=step/max(r,0.001);
if self.level<=0.0: self.level,self.state=0.0,'IDLE'
return self.level
class StereoMasterFilter:
def __init__(self): self.zi = np.zeros((2, 2))
def process(self, sig_stereo_int16, cut, res):
sig_float = sig_stereo_int16.astype(np.float32) / 32768.0
fc = max(50.0, min(cut, SAMPLE_RATE * 0.45)); q = 0.7 + (res * 9.0)
b, a = design_biquad(params.filter_mode, fc, q, SAMPLE_RATE)
sig_reshaped = sig_float.reshape(-1, 2)
out_reshaped, self.zi = lfilter(b, a, sig_reshaped, axis=0, zi=self.zi)
return np.clip(out_reshaped.flatten() * 32768.0, -32768, 32767).astype(np.int16)
class Oscillator:
def __init__(self): self.p=0.0; self.f=0.0
def set_f(self,f): self.f=f
def get(self,n,lfo):
if self.f==0: return np.zeros(n)
mf=self.f+(lfo*params.lfo_depth*10.0)
ph=self.p+np.cumsum(np.full(n,1.0))*(mf/SAMPLE_RATE)
self.p=ph[-1]%1.0; ph%=1.0
w=params.waveform
if w=='sawtooth': return 2.0*(ph-0.5)
elif w=='square': return np.sign(np.sin(2*np.pi*ph))
elif w=='sine': return np.sin(2*np.pi*ph)
return 2.0*np.abs(2.0*(ph-0.5))-1.0
class LFO:
def __init__(self): self.p=0.0
def get(self,n):
t=(np.arange(n)+self.p)/SAMPLE_RATE; self.p+=n
if self.p>SAMPLE_RATE: self.p-=SAMPLE_RATE
return np.sin(2*np.pi*params.lfo_rate*t)
vco=Oscillator(); lfo=LFO(); amp=ADSREnvelope(True); vcf=ADSREnvelope(False)
master_filter = StereoMasterFilter()
active_note=None
# --- Audio Callback ---
def sd_callback(outdata, frames, time_info, status):
global last_audio_data
if not is_running: outdata.fill(0); return
if params.engine_mode == "ANALOG":
raw = vco.get(frames, np.mean(lfo.get(frames)))
an_mono = raw * amp.get_val()
an_stereo_int16 = (np.repeat(an_mono, 2) * 0.8 * 32767).astype(np.int16)
else:
an_stereo_int16 = np.zeros(frames * 2, dtype=np.int16)
dig_stereo_int16 = fluid_engine.get_samples(frames)
mixed_int16 = np.clip(an_stereo_int16.astype(np.int32) + dig_stereo_int16.astype(np.int32), -32768, 32767).astype(np.int16)
apply_filter = False
if params.engine_mode == "ANALOG": apply_filter = True
elif params.engine_mode == "DIGITAL1": apply_filter = True
if apply_filter:
v_env = vcf.get_val()
cut = params.cutoff_base + (params.vcf_env_amt * v_env)
final_out = master_filter.process(mixed_int16, cut, params.resonance)
else:
vcf.get_val()
final_out = mixed_int16
final_out = (final_out * params.master_gain).astype(np.int16)
if len(last_audio_data) != len(final_out):
last_audio_data = np.zeros_like(final_out)
last_audio_data[:] = final_out
outdata[:] = final_out.reshape(-1, 2)
# --- Display Manager ---
class DisplayManager:
def __init__(self):
spi = board.SPI()
cs = digitalio.DigitalInOut(CS_PIN)
dc = digitalio.DigitalInOut(DC_PIN)
rst = digitalio.DigitalInOut(RST_PIN)
self.disp = st7789.ST7789(
spi, cs=cs, dc=dc, rst=rst,
baudrate=BAUDRATE,
width=240, height=320,
x_offset=0, y_offset=0,
rotation=0
)
self.width = 320
self.height = 240
self.image = Image.new("RGB", (self.width, self.height))
self.draw = ImageDraw.Draw(self.image)
try:
self.font_s = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
self.font_m = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
self.font_l = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
except:
self.font_s = ImageFont.load_default()
self.font_m = ImageFont.load_default()
self.font_l = ImageFont.load_default()
self.last_sys_update = 0
self.cache_ip = ""; self.cache_disk = ""; self.cache_ram = ""
def update(self):
self.draw.rectangle((0, 0, self.width, self.height), outline=0, fill=0)
gate_color = (255, 0, 0) if params.gate_status else (50, 0, 0)
self.draw.ellipse((5, 5, 20, 20), fill=gate_color)
page_names = ["MAIN", "LFO", "ADSR/FLT", "VISUAL", "LOG", "PLAYER", "SYSTEM", "EFFECT", "CHORD"]
title = f"{page_names[params.page_index]} (PG:{params.page_index+1}/9)"
self.draw.text((30, 5), title, font=self.font_m, fill=(200, 200, 200))
self.draw.line((0, 30, self.width, 30), fill=(100, 100, 100))
if params.page_index == 0: self._draw_page_main()
elif params.page_index == 1: self._draw_page_lfo()
elif params.page_index == 2: self._draw_page_adsr()
elif params.page_index == 3: self._draw_page_scope()
elif params.page_index == 4: self._draw_page_log()
elif params.page_index == 5: self._draw_page_player()
elif params.page_index == 6: self._draw_page_system()
elif params.page_index == 7: self._draw_page_effect()
elif params.page_index == 8: self._draw_page_chord()
self.disp.image(self.image.rotate(90, expand=True))
def _draw_page_main(self):
if params.engine_mode == "ANALOG":
mode_color = (255, 165, 0)
txt = f"WAVE: {params.waveform.upper()}"
elif params.engine_mode == "DIGITAL1":
mode_color = (0, 191, 255)
p = params.sf_program
name = GM_INSTRUMENTS[p] if p < len(GM_INSTRUMENTS) else "?"
txt = f"PROG {p}: {name}"
elif params.engine_mode == "DIGITAL2":
mode_color = (100, 255, 100)
p = params.sf_program
name = GM_INSTRUMENTS[p] if p < len(GM_INSTRUMENTS) else "?"
txt = f"PROG {p}: {name}"
else:
mode_color = (255, 100, 255)
p = params.sf_program
name = GM_INSTRUMENTS[p] if p < len(GM_INSTRUMENTS) else "?"
txt = f"PROG {p}: {name}"
self.draw.text((10, 40), f"MODE: {params.engine_mode}", font=self.font_l, fill=mode_color)
self.draw.text((10, 80), txt, font=self.font_m, fill=(255, 255, 255))
if "DIGITAL" in params.engine_mode:
self.draw.text((10, 110), "Use K3 to Select Prog", font=self.font_s, fill=(100, 100, 255))
if params.engine_mode == "DIGITAL2":
self.draw.text((180, 45), "(Filt OFF)", font=self.font_s, fill=(100, 255, 100))
elif params.engine_mode == "DIGITAL3":
self.draw.text((180, 45), "(Fixed Vel)", font=self.font_s, fill=(255, 100, 255))
if last_drum_name:
self.draw.text((10, 135), f"Drum: {last_drum_name}", font=self.font_m, fill=(255, 100, 100))
vol_pct = int(params.master_gain * 100)
self.draw.text((10, 160), f"VOL: {vol_pct}%", font=self.font_m, fill=(0, 255, 0))
bar_w = 180
self.draw.rectangle((10, 185, 10+bar_w, 200), outline=(0, 255, 0), fill=None)
self.draw.rectangle((10, 185, 10+int(bar_w*params.master_gain), 200), fill=(0, 255, 0))
def _draw_page_lfo(self):
self.draw.text((10, 40), f"RATE: {params.lfo_rate:.1f} Hz", font=self.font_l, fill=(255, 255, 0))
self.draw.text((10, 70), f"DEPTH: {int(params.lfo_depth*100)} %", font=self.font_l, fill=(0, 255, 255))
self.draw.text((10, 100), "Knob K1: Rate", font=self.font_s, fill=(200, 200, 200))
self.draw.text((10, 120), "Knob K2: Depth", font=self.font_s, fill=(200, 200, 200))
cx, cy = 160, 180
w, h = 280, 60
t = time.time()
phase = t * params.lfo_rate * 2.0 * math.pi
points = []
for i in range(w):
x = i
amp = (params.lfo_depth * 25.0) + 2.0
y = math.sin(phase + (i * 0.1)) * amp
points.append((20 + x, cy + y))
if len(points) > 1:
self.draw.line(points, fill=(255, 0, 255), width=2)
self.draw.rectangle((20, cy-30, 20+w, cy+30), outline=(50, 50, 50))
def _draw_page_adsr(self):
self.draw.text((10, 40), f"A:{params.vcf_attack:.2f} D:{params.vcf_decay:.2f}", font=self.font_m, fill=(255, 255, 255))
self.draw.text((10, 65), f"S:{params.vcf_sustain:.2f} R:{params.vcf_release:.2f}", font=self.font_m, fill=(255, 255, 255))
self.draw.text((10, 90), f"CUT: {int(params.cutoff_base)}", font=self.font_m, fill=(255, 255, 0))
self.draw.text((160, 90), f"RES: {params.resonance:.1f}", font=self.font_m, fill=(255, 0, 255))
self.draw.text((10, 115), "K1:A K2:D K3:S K4:R", font=self.font_s, fill=(150, 150, 150))
self.draw.text((10, 130), "K5:Cut K7:Res", font=self.font_s, fill=(150, 150, 150))
gx, gy, gw, gh = 20, 220, 280, 80
total_t = params.vcf_attack + params.vcf_decay + params.vcf_release + 0.5
scale_x = gw / max(total_t, 1.0)
x0, y0 = gx, gy
xa = x0 + (params.vcf_attack * scale_x)
ya = gy - gh
xd = xa + (params.vcf_decay * scale_x)
ys = gy - (params.vcf_sustain * gh)
hold_w = 0.5 * scale_x
xr_start = xd + hold_w
xr_end = xr_start + (params.vcf_release * scale_x)
pts = [(x0, y0), (xa, ya), (xd, ys), (xr_start, ys), (xr_end, y0)]
self.draw.line(pts, fill=(0, 255, 0), width=3)
self.draw.line((gx, gy, gx+gw, gy), fill=(100, 100, 100))
def _draw_page_scope(self):
step = 4
scale_y = 60.0
mid_y = 80
points = []
data = last_audio_data[::2]
for x in range(0, self.width, step):
idx = int((x / self.width) * len(data))
if idx < len(data):
val = data[idx] / 32768.0
y = mid_y - (val * scale_y)
points.append((x, y))
if len(points) > 1:
self.draw.line(points, fill=(0, 255, 0), width=2)
self.draw.text((5, 140), "Waveform (Up) / Spectrum (Down)", font=self.font_s, fill=(150, 150, 150))
# FFT Spectrum
try:
fft_data = np.abs(np.fft.rfft(data))
fft_len = len(fft_data)
display_len = min(fft_len, 64)
bar_width = self.width / display_len
bottom_y = 230
max_height = 80.0
for i in range(display_len):
mag = fft_data[i]
if i == 0: mag = 0
h = min(max_height, (mag / 400000.0) * max_height)
x1 = i * bar_width
y1 = bottom_y - h
x2 = x1 + bar_width - 1
y2 = bottom_y
r = int(255 * (i / display_len))
b = 255 - r
self.draw.rectangle((x1, y1, x2, y2), fill=(r, 100, b))
except: pass
def _draw_page_log(self):
y = 40
recent = midi_logs[-8:]
for line in recent:
self.draw.text((5, y), line, font=self.font_s, fill=(0, 255, 255))
y += 20
def _draw_page_player(self):
self.draw.text((10, 40), "MIDI File Player", font=self.font_l, fill=(0, 255, 255))
status_txt = "PLAYING >>" if midi_player.is_playing else "STOPPED ||"
status_col = (0, 255, 0) if midi_player.is_playing else (255, 100, 100)
self.draw.text((10, 70), status_txt, font=self.font_l, fill=status_col)
self.draw.text((10, 110), "K1: Select File", font=self.font_s, fill=(200, 200, 200))
self.draw.text((10, 130), "K2: Play(Right)/Stop(Left)", font=self.font_s, fill=(200, 200, 200))
start_y = 160
files = midi_player.files
idx = midi_player.selected_idx
if idx < 1: window_start = 0
elif idx >= len(files) - 1: window_start = len(files) - 3
else: window_start = idx - 1
window_start = max(0, window_start)
for i in range(window_start, min(len(files), window_start + 3)):
fname = files[i]
y = start_y + (i - window_start) * 25
prefix = "> " if i == idx else " "
col = (255, 255, 0) if i == idx else (200, 200, 200)
if len(fname) > 20: fname = fname[:18] + ".."
self.draw.text((10, y), prefix + fname, font=self.font_m, fill=col)
def _draw_page_system(self):
now = time.time()
if now - self.last_sys_update > 2.0:
self.cache_ip = get_ip_address()
self.cache_disk = get_disk_usage()
self.cache_ram = get_ram_usage()
self.last_sys_update = now
self.draw.text((10, 40), "IP Address:", font=self.font_m, fill=(200, 200, 200))
self.draw.text((10, 60), self.cache_ip, font=self.font_l, fill=(0, 255, 255))
temp = get_cpu_temp()
t_col = (0, 255, 0)
if temp > 60: t_col = (255, 255, 0)
if temp > 70: t_col = (255, 0, 0)
self.draw.text((10, 90), f"CPU Temp: {temp:.1f} C", font=self.font_m, fill=t_col)
self.draw.text((10, 120), "RAM Usage:", font=self.font_s, fill=(200, 200, 200))
self.draw.text((10, 135), self.cache_ram, font=self.font_m, fill=(255, 255, 255))
self.draw.text((10, 160), "Disk Usage:", font=self.font_s, fill=(200, 200, 200))
self.draw.text((10, 175), self.cache_disk, font=self.font_m, fill=(255, 255, 255))
def _draw_page_effect(self):
self.draw.text((10, 40), "Global Effects", font=self.font_l, fill=(255, 0, 255))
self.draw.text((10, 80), f"Reverb Send: {params.reverb_send}", font=self.font_m, fill=(255, 255, 0))
bar_w = 200
self.draw.rectangle((10, 105, 10+bar_w, 115), outline=(100, 100, 100))
self.draw.rectangle((10, 105, 10+int(bar_w*(params.reverb_send/127.0)), 115), fill=(255, 255, 0))
self.draw.text((10, 140), f"Chorus Send: {params.chorus_send}", font=self.font_m, fill=(0, 255, 255))
self.draw.rectangle((10, 165, 10+bar_w, 175), outline=(100, 100, 100))
self.draw.rectangle((10, 165, 10+int(bar_w*(params.chorus_send/127.0)), 175), fill=(0, 255, 255))
self.draw.text((10, 200), "K1: Reverb K2: Chorus", font=self.font_s, fill=(200, 200, 200))
def _draw_page_chord(self):
self.draw.text((10, 40), "Chord & Arpeggiator", font=self.font_l, fill=(255, 100, 100))
# 1. Type
idx = params.chord_type_idx
c_name = CHORD_TYPES[idx]["name"]
col = (100, 100, 100) if idx == 0 else (255, 255, 0)
self.draw.text((10, 70), f"TYPE: {c_name}", font=self.font_m, fill=col)
# 2. BPM
self.draw.text((10, 100), f"BPM: {int(params.arp_bpm)}", font=self.font_m, fill=(0, 255, 255))
# 3. Pattern
p_idx = params.arp_pattern_idx
p_name = ARP_PATTERNS[p_idx]
col_p = (100, 100, 100) if p_idx == 0 else (255, 0, 255)
self.draw.text((10, 130), f"ARP: {p_name}", font=self.font_m, fill=col_p)
self.draw.text((10, 180), "K1/Pad:Type K2:BPM", font=self.font_s, fill=(150, 150, 150))
self.draw.text((10, 200), "K3:Arp Pattern", font=self.font_s, fill=(150, 150, 150))
disp_mgr = None
# --- MIDI Handling ---
def handle_cc(n, v):
norm = v / 127.0
# K6: Page Select (9 Pages now)
if n == 6:
pg = int(norm * 9)
if pg > 8: pg = 8
if params.page_index != pg: params.page_index = pg
return
if n == 8: params.master_gain = norm; return
if params.page_index == 0:
if n == 7:
idx = min(6, int(norm * 7))
if idx == 4: params.engine_mode = "DIGITAL1"
elif idx == 5: params.engine_mode = "DIGITAL2"
elif idx == 6: params.engine_mode = "DIGITAL3"
else:
params.engine_mode = "ANALOG"
params.waveform = ["sawtooth", "square", "sine", "triangle"][idx]
return
if n == 3 and "DIGITAL" in params.engine_mode:
prog = v
if params.sf_program != prog:
params.sf_program = prog
fluid_engine.change_program(0, prog)
return
elif params.page_index == 1:
if n == 1: params.lfo_rate = 0.1 + norm * 19.9
elif n == 2: params.lfo_depth = norm
return
elif params.page_index == 2:
if n == 1: params.vcf_attack = 0.01 + norm * 2
elif n == 2: params.vcf_decay = 0.01 + norm * 2
elif n == 3: params.vcf_sustain = norm
elif n == 4: params.vcf_release = 0.01 + norm * 3
elif n == 5: params.cutoff_base = 50.0 + norm * 5000.0
elif n == 7: params.resonance = norm * 0.95
return
elif params.page_index == 5:
if n == 1: midi_player.select_file(norm)
elif n == 2:
if v > 64: midi_player.play()
else: midi_player.stop()
elif params.page_index == 7: # EFFECT
if n == 1:
params.reverb_send = v
fluid_engine.send_cc(0, 91, v)
elif n == 2:
params.chorus_send = v
fluid_engine.send_cc(0, 93, v)
elif n == 3:
params.master_gain = norm
elif params.page_index == 8: # CHORD & ARP
if n == 1:
idx = int(norm * len(CHORD_TYPES))
if idx >= len(CHORD_TYPES): idx = len(CHORD_TYPES) - 1
params.chord_type_idx = idx
elif n == 2: # BPM
params.arp_bpm = 60.0 + (norm * 240.0) # 60-300
elif n == 3: # Pattern
idx = int(norm * len(ARP_PATTERNS))
if idx >= len(ARP_PATTERNS): idx = len(ARP_PATTERNS) - 1
params.arp_pattern_idx = idx
def get_note_name(note_num):
notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
octave = note_num // 12 - 1
name = notes[note_num % 12]
return f"{name}{octave}"
def add_log(msg):
midi_logs.append(msg)
if len(midi_logs) > 20: midi_logs.pop(0)
def process_midi_event(status, data1, data2):
global active_note, last_drum_name
st = status & 0xF0
ch = status & 0x0F
log_str = ""
# Note On
if st == 0x90 and data2 > 0:
n, v = data1, data2
# Pad Chord Select (Only in Chord Page)
if params.page_index == 8:
if 36 <= n <= 43:
idx = n - 36
params.chord_type_idx = idx
log_str = f"Chord Type: {CHORD_TYPES[idx]['name']}"
add_log(log_str)
return
elif 44 <= n <= 51:
idx = n - 44
params.chord_type_idx = idx
log_str = f"Chord Type: {CHORD_TYPES[idx]['name']}"
add_log(log_str)
return
# Drum Check
is_drum = (ch == 9) or (36 <= n <= 51 and ch == 0)
if is_drum:
target_ch = 9
v_drum = max(v, 64)
fluid_engine.note_on(target_ch, n, v_drum)
d_name = DRUM_MAP.get(n, "Drum")
last_drum_name = d_name
log_str = f"Ch{ch} Pad: {d_name}"
add_log(log_str)
return
# Normal Note
if ch == 0 and params.engine_mode == "DIGITAL3": v = 102
else: v = min(127, v + 40)
log_str = f"Ch{ch} NoteOn: {n}"
# Chord Mode Processing (Only for Ch0)
if ch == 0:
# 1. Generate Notes
notes_to_play = []
if params.chord_type_idx > 0:
offsets = CHORD_TYPES[params.chord_type_idx]["offsets"]
for off in offsets:
chord_n = n + off
if 0 <= chord_n <= 127: notes_to_play.append((chord_n, v))
else:
notes_to_play.append((n, v))
# 2. Check ARP status
is_arp = (params.arp_pattern_idx > 0)
if is_arp:
arp.set_notes(notes_to_play)
# For Analog Scope visual only
if params.engine_mode == "ANALOG": active_note=n; params.gate_status=True
else: params.gate_status=True
else:
# Normal Chord Play
for (cn, cv) in notes_to_play:
fluid_engine.note_on(ch, cn, cv)
if params.engine_mode == "ANALOG":
vco.set_f(440.0*(2**((n-69)/12.0))); amp.trigger(); vcf.trigger(); active_note=n; params.gate_status=True
else:
vcf.trigger(); params.gate_status=True
# Note Off
elif (st == 0x80) or (st == 0x90 and data2 == 0):
n = data1
if params.page_index == 8 and (36 <= n <= 51): return
is_drum = (ch == 9) or (36 <= n <= 51 and ch == 0)
if is_drum:
fluid_engine.note_off(9, n)
return
log_str = f"Ch{ch} NoteOff: {n}"
if ch == 0:
is_arp = (params.arp_pattern_idx > 0)
if is_arp:
# Stop Arp
arp.set_notes([])
if params.engine_mode == "ANALOG":
if n == active_note: amp.release(); vcf.release(); active_note=None; params.gate_status=False
else:
vcf.release(); params.gate_status=False
else:
# Normal Note Off
if params.chord_type_idx > 0:
offsets = CHORD_TYPES[params.chord_type_idx]["offsets"]
for off in offsets:
chord_n = n + off
if 0 <= chord_n <= 127: fluid_engine.note_off(ch, chord_n)
else:
fluid_engine.note_off(ch, n)
if params.engine_mode == "ANALOG":
if n == active_note: amp.release(); vcf.release(); active_note=None; params.gate_status=False
else:
vcf.release(); params.gate_status=False
elif st == 0xB0:
if ch == 0:
handle_cc(data1, data2)
log_str = f"CC #{data1} Val:{data2}"
else:
fluid_engine.send_cc(ch, data1, data2)
elif st == 0xC0:
if "DIGITAL" in params.engine_mode or ch != 0:
params.sf_program = data1
fluid_engine.change_program(ch, data1)
log_str = f"Ch{ch} PC: {data1}"
if log_str and ch == 0: add_log(log_str)
def midi_loop(midi_in):
while is_running:
msg = midi_in.get_message()
if msg:
m, _ = msg
if len(m) >= 2: process_midi_event(m[0], m[1], m[2] if len(m)>2 else 0)
time.sleep(0.001)
# --- Main ---
if __name__ == "__main__":
print("Initializing Synth (v60: Arpeggiator)...")
disp_mgr = DisplayManager()
print("Searching for audio device with keyword: 'USB'...")
devs = sd.query_devices()
usb_idx = None
for i, d in enumerate(devs):
if TARGET_DEVICE_KEYWORD in d['name'] and d['max_output_channels'] > 0:
usb_idx = i
print(f"Found Target Device: {d['name']} (Index: {i})")
break
if usb_idx is None:
print("Warning: USB Device not found. Using system default.")
fluid_engine.init_synth()
mi=rtmidi.MidiIn()
ports = mi.get_ports(); found_idx = -1
for i, p_name in enumerate(ports):
if "Midi Through" not in p_name: found_idx = i; break
if found_idx != -1: mi.open_port(found_idx); print(f"MIDI Connected: {ports[found_idx]}")
else: mi.open_virtual_port("PySynth"); print("MIDI: Virtual Port")
threading.Thread(target=midi_loop,args=(mi,),daemon=True).start()
print("Starting Audio Stream...")
stream = sd.OutputStream(
device=usb_idx,
channels=CHANNELS,
samplerate=SAMPLE_RATE,
dtype=DTYPE,
blocksize=BLOCK_SIZE,
latency='high',
callback=sd_callback
)
stream.start()
print("System Running.")
try:
while True:
start = time.time()
disp_mgr.update()
elapsed = time.time() - start
if elapsed < 0.05:
time.sleep(0.05 - elapsed)
except KeyboardInterrupt:
print("\nStopping...")
is_running = False
midi_player.stop()
stream.stop()
mi.close_port()
print("Bye!")