Skip to content

Instantly share code, notes, and snippets.

@acidsound
Last active October 26, 2025 16:52
Show Gist options
  • Select an option

  • Save acidsound/37051a0ce90ce94918bccebe112af452 to your computer and use it in GitHub Desktop.

Select an option

Save acidsound/37051a0ce90ce94918bccebe112af452 to your computer and use it in GitHub Desktop.
polypony sine/sawtooth synth - add realtime blender + drum synth
#include <Arduino.h>
#include <NimBLEDevice.h>
#include <U8g2lib.h>
#include "driver/i2s.h"
// #define DEBUG // Comment out this line to disable debug output
// Debug printing macros
#ifdef DEBUG
#define DEBUG_PRINT(x) Serial.print(x)
#define DEBUG_PRINTLN(x) Serial.println(x)
#define DEBUG_PRINTF(fmt, ...) Serial.printf(fmt, ##__VA_ARGS__)
#else
#define DEBUG_PRINT(x)
#define DEBUG_PRINTLN(x)
#define DEBUG_PRINTF(fmt, ...)
#endif
/* DRUM MAP */
/*
40 41 42 43 48 49 50 51
36 37 38 39 44 45 46 47
*/
// User notification macros (always active)
#define USER_PRINT(x) Serial.print(x)
#define USER_PRINTLN(x) Serial.println(x)
#define USER_PRINTF(fmt, ...) Serial.printf(fmt, ##__VA_ARGS__)
// Structure to hold MIDI note event data
typedef struct {
uint8_t note;
uint8_t velocity;
bool isNoteOn;
uint8_t channel;
} NoteEvent_t;
// Structure to track active notes for polyphony
#define MAX_POLYPHONY 4 // Maximum number of simultaneous notes
typedef struct {
uint8_t note;
uint8_t velocity;
float frequency;
bool active;
} ActiveNote_t;
// BLE MIDI 서비스 고유 UUID
static NimBLEUUID midiServiceUUID("03B80E5A-EDE8-4B33-A751-6CE34EC4C700");
// BLE MIDI characteristic UUID - used for both TX (device to central) and RX (central to device)
static NimBLEUUID midiCharacteristicUUID("7772E5DB-3868-4112-A1A9-F2669D106BF3");
// 스캔 후 찾은 장치 포인터
NimBLEAdvertisedDevice* myDevice = nullptr;
bool connected = false;
std::string connectedDeviceName;
// BLE Client and related objects
NimBLEClient* pClient = nullptr;
NimBLERemoteService* pRemoteService = nullptr;
NimBLERemoteCharacteristic* pMidiTXCharacteristic = nullptr;
// there is no 72x40 constructor in u8g2 hence the 72x40 screen is mapped in the middle of the 132x64 pixel buffer of the SSD1306 controller
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE, 6, 5);
int width = 72;
int height = 40;
int xOffset = 30; // = (132-w)/2
int yOffset = 12; // = (64-h)/2
// LED pin definition
const int LED_PIN = 8;
// I2S configuration for PCM5102A
#define I2S_PORT I2S_NUM_0
#define I2S_BCLK_PIN 3 // BCK on PCM5102A
#define I2S_LRCK_PIN 2 // LCK on PCM5102A
#define I2S_DOUT_PIN 7 // DIN on PCM5102A
// Audio parameters
#define SAMPLE_RATE 22050
#define SAMPLE_BUFFER_SIZE 256
#define WAVE_TABLE_SIZE 128 // Reduced for square/sawtooth wave performance
#define DMA_BUF_COUNT 8
#define DMA_BUF_LEN 256
// Audio generation variables
bool audioPlaying = false;
float currentFrequency = 440.0f; // Default to A4 (440Hz)
float currentPhase = 0.0f; // Maintain phase continuity between notes
uint8_t currentVelocity = 100; // Current MIDI velocity (0-127), default to medium
uint8_t currentNote = 0; // Currently playing MIDI note number (0 = no note playing)
// Polyphony variables
ActiveNote_t activeNotes[MAX_POLYPHONY]; // Track up to MAX_POLYPHONY simultaneous notes
int activeNoteCount = 0; // Number of currently active notes
// Pitch bend variables
int16_t pitchBendValue = 0; // Current pitch bend value (-8192 to 8191)
int16_t pitchBendRange = 2; // Pitch bend range in semitones (default to 2)
// Aftertouch variables
uint8_t channelAftertouch = 0; // Current channel aftertouch value
uint8_t polyAftertouch[MAX_POLYPHONY] = {0}; // Polyphonic aftertouch for each note
// Note phase tracking for polyphony
float notePhase[MAX_POLYPHONY] = {0.0f}; // Store phase for each note
// Audio quality settings
int waveformType = 1; // 0=sawtooth, 1=square (default to square for better performance)
// Waveform mixing parameters for real-time waveform blending
float waveformMixRatio = 1.0f; // 1.0 = 100% square, 0.0 = 100% sawtooth
bool useWaveformMixing = true; // Flag to enable/disable real-time waveform mixing
// Drum Synth Parameters
const int DRUM_CHANNEL = 9; // MIDI channel 10 is represented as index 9
bool isDrumChannel = false; // Flag to indicate if we're processing drum channel
// TR-909 Drum Names for debugging - only instrument names
const char* DRUM_INSTRUMENT_NAMES[] = {
"Bass Drum", // Note 35
"Bass Drum 2", // Note 36
"Rim Shot",
"Snare Drum",
"Hand Clap",
"Snare Drum", // Note 40
"Low Tom",
"Closed High Hat",
"Low Tom", // Note 43
"Closed High Hat", // Note 44
"Mid Tom",
"Open High Hat",
"Mid Tom", // Note 47
"High Tom",
"Crash Cymbal",
"High Tom", // Note 50
"Ride Cymbal"
};
// Maximum number of simultaneous drum sounds
#define MAX_DRUM_VOICES 8
// Structure to track individual drum sounds
struct DrumVoice {
bool active;
int note;
float phase;
float amplitude;
float envelopePhase;
unsigned long startTime;
float lastOutputSample; // For anti-aliasing filter
};
// TR-909 Kick Drum Parameters
float kickTone = 40.0f; // Hz, 기본 킥 피치 (낮춤)
float kickDecay = 0.35f; // 초, 음의 지속 시간 (조정)
float kickEnvDepth = 36.0f; // 세미톤, 피치 엔벌로프 범위 (더 급격한 피치 변화)
float kickNoiseAmt = 0.1f; // 잡음 비율 (조정)
// Array to manage multiple drum voices
DrumVoice drumVoices[MAX_DRUM_VOICES];
int activeDrumCount = 0; // Number of currently active drum voices
// Initialize drum voices
void initDrumVoices() {
for (int i = 0; i < MAX_DRUM_VOICES; i++) {
drumVoices[i].active = false;
drumVoices[i].note = 0;
drumVoices[i].phase = 0.0f;
drumVoices[i].amplitude = 0.0f;
drumVoices[i].envelopePhase = 0.0f;
drumVoices[i].startTime = 0;
drumVoices[i].lastOutputSample = 0.0f;
}
}
// Anti-aliasing filter state for main audio (not individual drums)
float lastOutputSample = 0.0f; // For simple low-pass filtering
float filterCoefficient = 0.3f; // Controls the strength of the filter (0.0 = no filtering, 1.0 = maximum)
TaskHandle_t audioTaskHandle = NULL;
// Queue for MIDI note events between tasks
QueueHandle_t noteEventQueue;
unsigned long lastMarqMillis = 0;
int marqPos = 0;
// LED blinking tracking
unsigned long lastBlinkMillis = 0;
bool ledState = false;
const unsigned long BLINK_INTERVAL = 500; // 0.5 second interval
// MIDI activity tracking
unsigned long lastMidiActivity = 0;
bool midiActivity = false;
unsigned long midiActivityTimeout = 1000; // 1 second timeout for MIDI activity indicator
// Variables to store latest MIDI data for display
int lastMidiChannel = 0;
int lastMidiCommand = 0;
int lastMidiData1 = 0;
int lastMidiData2 = 0;
bool hasMidiData = false;
void updateMidiActivity() {
// Turn off MIDI activity indicator if timeout has passed
if (midiActivity && (millis() - lastMidiActivity) > midiActivityTimeout) {
midiActivity = false;
}
}
void updateLEDBlinking() {
unsigned long currentMillis = millis();
if (!connected) {
// When not connected, blink the LED (inverted logic)
if (currentMillis - lastBlinkMillis >= BLINK_INTERVAL) {
lastBlinkMillis = currentMillis;
ledState = !ledState;
digitalWrite(LED_PIN, !ledState); // Invert the LED state
}
// Ensure LED starts blinking immediately if not connected and it's been a while since last update
// This handles the case where it might not blink immediately after reset
if (lastBlinkMillis == 0) {
lastBlinkMillis = currentMillis;
ledState = true; // Start with logical LED on (physical LED off due to inversion)
digitalWrite(LED_PIN, !ledState); // Invert the LED state
}
} else {
// When connected, ensure LED is off (inverted logic)
if (ledState != false) {
ledState = false;
digitalWrite(LED_PIN, HIGH); // Turn off LED when connected (inverted logic)
} else {
digitalWrite(LED_PIN, HIGH); // Ensure LED is off when connected
}
}
}
// Initialize I2S for PCM5102A (MCLK-less/PLL mode)
void initI2S() {
i2s_config_t i2s_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
.sample_rate = SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT, // Stereo
.communication_format = (i2s_comm_format_t)(I2S_COMM_FORMAT_STAND_I2S | I2S_COMM_FORMAT_I2S_MSB),
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = DMA_BUF_COUNT,
.dma_buf_len = DMA_BUF_LEN,
.use_apll = false, // Don't use APLL for MCLK-less mode
.tx_desc_auto_clear = true,
.fixed_mclk = 0,
.mclk_multiple = I2S_MCLK_MULTIPLE_256, // For 44.1kHz
.bits_per_chan = I2S_BITS_PER_CHAN_DEFAULT
};
i2s_pin_config_t pin_config = {
.mck_io_num = I2S_PIN_NO_CHANGE, // MCLK is not used for PCM5102A
.bck_io_num = I2S_BCLK_PIN,
.ws_io_num = I2S_LRCK_PIN,
.data_out_num = I2S_DOUT_PIN,
.data_in_num = I2S_PIN_NO_CHANGE
};
i2s_driver_install(I2S_PORT, &i2s_config, 0, NULL);
i2s_set_pin(I2S_PORT, &pin_config);
}
// Convert MIDI note number to frequency using equal temperament formula
// f(n) = 440 * 2^((n-69)/12) where n is the MIDI note number
float noteToFrequency(uint8_t note) {
return 440.0f * pow(2.0f, (note - 69) / 12.0f);
}
// Audio task function that runs independently
void audioTask(void *pvParameters) {
const int samplesPerBuffer = SAMPLE_BUFFER_SIZE; // For stereo, this is still the total number of samples
int16_t* audioBuffer = (int16_t*)malloc(sizeof(int16_t) * samplesPerBuffer);
while(true) {
// Process any incoming note events from the queue
NoteEvent_t noteEvent;
// Only process queue if it exists
if (noteEventQueue != NULL) {
while (xQueueReceive(noteEventQueue, &noteEvent, 0) == pdTRUE) { // Non-blocking receive to process all pending events
if (noteEvent.isNoteOn && noteEvent.velocity > 0) {
// Note On
// Find an available slot in the active notes array
int freeSlot = -1;
// Look for a free slot using round-robin to better distribute voices
static int lastUsedSlot = 0;
for (int i = 0; i < MAX_POLYPHONY; i++) {
int slot = (lastUsedSlot + i) % MAX_POLYPHONY;
if (!activeNotes[slot].active) {
freeSlot = slot;
lastUsedSlot = (slot + 1) % MAX_POLYPHONY; // Move to next slot for future allocations
break;
}
}
// If no free slot and we've reached max polyphony, use a voice stealing algorithm
if (freeSlot == -1 && activeNoteCount >= MAX_POLYPHONY) {
// Implement round-robin voice stealing (replaces oldest voice)
freeSlot = lastUsedSlot;
lastUsedSlot = (lastUsedSlot + 1) % MAX_POLYPHONY;
} else if (freeSlot == -1) {
// If no free slot but we haven't reached max polyphony, this is an error state
// This shouldn't happen with proper logic, but we'll handle it anyway
continue; // Skip this note event
}
// If using an existing voice (stealing), decrement the active note count if necessary
if (activeNotes[freeSlot].active) {
activeNoteCount--;
}
// Set up the note in the free slot
activeNotes[freeSlot].note = noteEvent.note;
activeNotes[freeSlot].velocity = noteEvent.velocity;
activeNotes[freeSlot].frequency = noteToFrequency(noteEvent.note);
// Apply pitch bend to this note's frequency
float pitchBendSemitones = (pitchBendValue / 8192.0f) * pitchBendRange;
float pitchBendFactor = pow(2.0f, pitchBendSemitones / 12.0f);
activeNotes[freeSlot].frequency *= pitchBendFactor;
activeNotes[freeSlot].active = true;
activeNoteCount++;
audioPlaying = true;
// Update current note for display purposes (use the most recently played note)
currentNote = noteEvent.note;
currentVelocity = noteEvent.velocity;
} else {
// Note Off
for (int i = 0; i < MAX_POLYPHONY; i++) {
if (activeNotes[i].active && activeNotes[i].note == noteEvent.note) {
// Found the note to turn off
activeNotes[i].active = false;
activeNoteCount--;
// If no more active notes and no active drums, stop audio
if (activeNoteCount <= 0 && activeDrumCount <= 0) {
audioPlaying = false;
activeNoteCount = 0;
}
break;
}
}
}
} // End of while (xQueueReceive...)
} // End of if (noteEventQueue != NULL)
if (audioPlaying || activeDrumCount > 0) {
for (int i = 0; i < samplesPerBuffer; i += 2) { // Generate stereo samples
int32_t mixedSample = 0;
// Mix all active notes together with separate phase tracking for each note
for (int j = 0; j < MAX_POLYPHONY; j++) {
if (activeNotes[j].active) {
// Calculate wave table index based on current phase for this note
int waveIndex = (int)(notePhase[j] * WAVE_TABLE_SIZE) % WAVE_TABLE_SIZE;
int16_t sample;
// Real-time waveform mixing between square and sawtooth
if (useWaveformMixing) {
// Calculate square and sawtooth values in real-time based on waveIndex
// Square wave: 1 if waveIndex < half the table size, -1 otherwise
float squareValue = (waveIndex < (WAVE_TABLE_SIZE >> 1)) ? 1.0f : -1.0f;
float sawtoothValue = (2.0f * waveIndex / WAVE_TABLE_SIZE) - 1.0f; // Range: -1 to 1
// Mix the waveforms based on the current waveformMixRatio (now squareMixRatio)
// Using waveformMixRatio variable name but applying it to square wave mixing
float mixedValue = squareValue * waveformMixRatio + sawtoothValue * (1.0f - waveformMixRatio);
sample = (int16_t)(mixedValue * 32767.0f); // Scale back to 16-bit range
} else {
// Use the simple waveform selection as before
if (waveformType == 0) {
// Square wave: 1 if waveIndex < half the table size, -1 otherwise
sample = (waveIndex < (WAVE_TABLE_SIZE >> 1)) ? 32767 : -32767; // Pure square wave
} else {
float sawValue = (2.0f * waveIndex / WAVE_TABLE_SIZE) - 1.0f; // Range: -1 to 1
sample = (int16_t)(sawValue * 32767.0f); // Pure sawtooth wave
}
}
// Scale the sample amplitude based on MIDI velocity (0-127) and aftertouch
// Convert velocity to a scaling factor between 0.0 and 1.0
// Use multiplication instead of division for better performance
float velocityScale = (float)activeNotes[j].velocity * 0.007874015748f; // 1/127
// Apply aftertouch effect (simplified implementation - affects amplitude)
float aftertouchEffect = 1.0f + (polyAftertouch[j] * 0.00157480315f); // (1/127) * 0.2
float totalScale = velocityScale * aftertouchEffect;
// Divide by the number of active notes to maintain consistent volume
// Prevent division by zero by using at least 1
int divisor = activeNoteCount > 0 ? activeNoteCount : 1;
totalScale /= divisor; // Distribute volume across all active notes
int16_t scaledSample = (int16_t)(sample * totalScale);
// Add this sample to our mixed output
mixedSample += scaledSample;
// Update the phase for this specific note
// Use multiplication instead of division for better performance
notePhase[j] += activeNotes[j].frequency * (1.0f / SAMPLE_RATE);
if (notePhase[j] >= 1.0f) {
notePhase[j] -= 1.0f;
} else if (notePhase[j] < 0.0f) {
notePhase[j] += 1.0f;
}
}
}
// Process all active drum voices (only note 36 for now)
int processedVoices = 0;
for (int i = 0; i < MAX_DRUM_VOICES; i++) {
if (drumVoices[i].active) {
processedVoices++;
// Calculate elapsed time for this drum voice
unsigned long currentTime = millis();
float elapsed = (currentTime - drumVoices[i].startTime) / 1000.0f; // Convert to seconds
DEBUG_PRINTF("Processing drum voice %d: note=%d, elapsed=%.3f, amplitude=%.3f\n",
i, drumVoices[i].note, elapsed, drumVoices[i].amplitude);
int16_t drumSample;
// For note 36 (TR-909 Kick), use specialized algorithm
if (drumVoices[i].note == 36) {
DEBUG_PRINTF("Generating TR-909 kick sample for voice %d\n", i);
DEBUG_PRINTF(" Kick parameters - tone: %.2fHz, decay: %.3f, envDepth: %.2f, elapsed: %.3f\n",
kickTone, kickDecay, kickEnvDepth, elapsed);
// Generate TR-909 style kick drum sample
float kickSample = generateTR909KickSample(elapsed);
drumSample = (int16_t)(kickSample * 32767.0f * drumVoices[i].amplitude);
DEBUG_PRINTF("TR-909 kick sample: %.3f -> %d (after scaling by %.3f)\n",
kickSample, drumSample, drumVoices[i].amplitude);
} else {
// Skip processing for other notes (not implemented yet)
continue;
}
// Mix drum sample with the main mixed sample
// Add drum to the overall mix
DEBUG_PRINTF("Adding drum sample %d to mixedSample (was %ld)\n", drumSample, mixedSample);
mixedSample += drumSample;
DEBUG_PRINTF("mixedSample after adding drum: %ld\n", mixedSample);
// Check if this voice should be deactivated (only for note 36)
if (drumVoices[i].note == 36) {
// For kick drum, deactivate after its natural decay
if (elapsed > kickDecay) {
drumVoices[i].active = false;
if (activeDrumCount > 0) {
activeDrumCount--;
}
DEBUG_PRINTF("Deactivating kick drum voice %d, activeDrumCount now: %d\n", i, activeDrumCount);
}
}
}
}
if (processedVoices > 0) {
DEBUG_PRINTF("Processed %d drum voices in this audio buffer iteration\n", processedVoices);
}
// Apply a more sophisticated limiter to prevent clipping when mixing multiple notes
// Instead of hard limiting, use soft clipping algorithm
float softClippedSample;
if (mixedSample > 32767) {
// Soft clipping formula to reduce harsh distortion
softClippedSample = 32767.0f * (1.0f - expf(-(mixedSample / 32767.0f) * 2.0f));
} else if (mixedSample < -32768) {
// Soft clipping for negative values
softClippedSample = -32768.0f * (1.0f - expf(-(-mixedSample / 32768.0f) * 2.0f));
} else {
softClippedSample = mixedSample;
}
float currentSample = softClippedSample;
// Apply simple low-pass filter for anti-aliasing (optional)
float filteredSample = lastOutputSample * (1.0f - filterCoefficient) + currentSample * filterCoefficient;
lastOutputSample = filteredSample;
int16_t finalSample = (int16_t)filteredSample;
// Set both left and right channels with the mixed sample
audioBuffer[i] = finalSample; // Left channel
audioBuffer[i + 1] = finalSample; // Right channel
}
size_t bytesWritten;
i2s_write(I2S_PORT, audioBuffer, sizeof(int16_t) * samplesPerBuffer, &bytesWritten, portMAX_DELAY); // Restore blocking write for stability
} else {
// When not playing, briefly yield to other tasks
vTaskDelay(10 / portTICK_PERIOD_MS); // Restore to normal delay
}
}
free(audioBuffer);
vTaskDelete(NULL);
}
// Stop audio playback
void stopAudio() {
audioPlaying = false;
currentNote = 0; // Reset current note tracking
// Send a small amount of silence to ensure clean stop
int16_t silence[64]; // Small buffer for clean stop
for (int i = 0; i < 64; i++) {
silence[i] = 0;
}
size_t bytesWritten;
i2s_write(I2S_PORT, silence, sizeof(silence), &bytesWritten, portMAX_DELAY);
DEBUG_PRINTLN("audio STOP >>>>");
}
// Play a test sequence of C4, E4, G4 for debugging
// Each note plays for 100ms
void playTestSequence() {
DEBUG_PRINTLN("Playing test sequence: C4, E4, G4");
// Send note events via queue to maintain consistent handling
NoteEvent_t noteEvent;
// C4 = MIDI note 60 (261.63 Hz)
noteEvent.note = 60;
noteEvent.velocity = 100;
noteEvent.isNoteOn = true;
noteEvent.channel = 0;
if (noteEventQueue != NULL) {
xQueueSendToBack(noteEventQueue, &noteEvent, portMAX_DELAY);
}
delay(100); // Play for 100ms
// E4 = MIDI note 64 (329.63 Hz)
noteEvent.note = 64;
if (noteEventQueue != NULL) {
xQueueSendToBack(noteEventQueue, &noteEvent, portMAX_DELAY);
}
delay(100); // Play for 100ms
// G4 = MIDI note 67 (392.00 Hz)
noteEvent.note = 67;
if (noteEventQueue != NULL) {
xQueueSendToBack(noteEventQueue, &noteEvent, portMAX_DELAY);
}
delay(100); // Play for 100ms
// Stop current note
noteEvent.isNoteOn = false;
noteEvent.note = 67; // Stop the last note played
if (noteEventQueue != NULL) {
xQueueSendToBack(noteEventQueue, &noteEvent, portMAX_DELAY);
}
delay(50); // Small delay before complete stop
DEBUG_PRINTLN("Test sequence completed");
}
// Start audio playback with specific frequency
void startAudioWithFrequency(float frequency) {
currentFrequency = frequency;
// Reset phase to prevent clicks when changing frequency
currentPhase = 0.0f;
audioPlaying = true;
DEBUG_PRINTF("audio START >>>> Frequency: %.2f Hz\n", frequency);
}
void updateU8G2(const char* name) {
u8g2.clearBuffer();
u8g2.drawFrame(xOffset - 2, yOffset + 12, width, height);
if (connected && hasMidiData) {
// When connected and MIDI data has been received, show MIDI info
// Show MIDI activity indicator
if (midiActivity) {
// Draw a small filled circle as MIDI activity indicator
u8g2.drawDisc(xOffset + width - 5, yOffset + 15, 3); // Small filled circle
} else {
// Draw an outline circle when there's no MIDI activity
u8g2.drawCircle(xOffset + width - 5, yOffset + 15, 3); // Small outline circle
}
// Show MIDI data header
u8g2.setCursor(xOffset, yOffset + 23);
u8g2.printf("Ch Cmd D1 D2");
// Show the latest MIDI data
u8g2.setCursor(xOffset, yOffset + 35);
u8g2.printf("%02d %02X %03d %03d", lastMidiChannel, lastMidiCommand, lastMidiData1, lastMidiData2);
} else {
// When not connected or no MIDI data received yet, show connection info
u8g2.setCursor(xOffset, yOffset + 48);
u8g2.printf("%s", "@acidsound");
u8g2.setCursor(xOffset, yOffset + 23);
u8g2.println("::MIDI FROM");
size_t nameLen = strlen(name);
const int displayLen = 11;
char displayStr[displayLen + 1]; // +1 for null terminator
if (nameLen <= displayLen) {
// 문자열이 11자 이하면 그대로 표시
strncpy(displayStr, name, displayLen);
displayStr[nameLen] = '\0';
} else {
// marquee 효과: 맨 끝과 맨 앞 사이 공백 1칸 삽입
int scrollLen = nameLen + 1; // 공백 한 칸 추가한 길이
for (int i = 0; i < displayLen; i++) {
int idx = (marqPos + i) % scrollLen;
if (idx == nameLen) {
displayStr[i] = ' '; // 공백 삽입
} else {
displayStr[i] = name[idx];
}
}
displayStr[displayLen] = '\0';
}
u8g2.setCursor(xOffset, yOffset + 35);
u8g2.printf("%s", displayStr);
// Draw MIDI activity indicator (a small dot or rectangle that shows when MIDI is active)
if (midiActivity) {
// Draw a small filled circle as MIDI activity indicator
u8g2.drawDisc(xOffset + width - 5, yOffset + 15, 3); // Small filled circle
} else {
// Draw an outline circle when there's no MIDI activity
u8g2.drawCircle(xOffset + width - 5, yOffset + 15, 3); // Small outline circle
}
}
u8g2.sendBuffer();
}
// 스캔 결과를 처리할 콜백 클래스
class MyAdvertisedDeviceCallbacks : public NimBLEAdvertisedDeviceCallbacks {
void onResult(NimBLEAdvertisedDevice* advertisedDevice) override {
if (advertisedDevice->isAdvertisingService(midiServiceUUID)) {
DEBUG_PRINT("Found BLE MIDI Device: ");
DEBUG_PRINT(advertisedDevice->getName().c_str());
DEBUG_PRINT(" (Address: ");
DEBUG_PRINT(advertisedDevice->getAddress().toString().c_str());
DEBUG_PRINTLN(")");
connectedDeviceName = advertisedDevice->getName();
DEBUG_PRINTF("update device: %s", connectedDeviceName.c_str());
DEBUG_PRINTLN("");
if (myDevice == nullptr) {
DEBUG_PRINTLN("Connecting device");
myDevice = new NimBLEAdvertisedDevice(*advertisedDevice);
NimBLEDevice::getScan()->stop();
}
}
}
};
class MyClientCallback : public NimBLEClientCallbacks {
void onConnect(NimBLEClient* pclient) override {
USER_PRINTLN("Connected to BLE MIDI device");
connected = true;
}
void onDisconnect(NimBLEClient* pclient) override {
USER_PRINTLN("Disconnected from BLE MIDI device");
connected = false;
// Clean up - don't delete pClient as NimBLE manages it
pRemoteService = nullptr;
pMidiTXCharacteristic = nullptr;
// Reset audio state when disconnected
audioPlaying = false;
currentNote = 0;
// Reset all active notes
for (int i = 0; i < MAX_POLYPHONY; i++) {
activeNotes[i].active = false;
activeNotes[i].note = 0;
activeNotes[i].velocity = 0;
activeNotes[i].frequency = 0.0f;
notePhase[i] = 0.0f;
}
activeNoteCount = 0;
// Stop audio output
if (audioTaskHandle != NULL) {
// Send a small amount of silence to ensure clean stop
int16_t silence[64]; // Small buffer for clean stop
for (int i = 0; i < 64; i++) {
silence[i] = 0;
}
size_t bytesWritten;
i2s_write(I2S_PORT, silence, sizeof(silence), &bytesWritten, portMAX_DELAY);
}
}
};
void connectToDevice() {
if (myDevice == nullptr) {
DEBUG_PRINTLN("No device to connect to");
return;
}
DEBUG_PRINTLN("Creating BLE Client...");
pClient = NimBLEDevice::createClient();
if (pClient == nullptr) {
DEBUG_PRINTLN("Failed to create BLE client");
connected = false;
return;
}
DEBUG_PRINTLN("Setting client callbacks...");
pClient->setClientCallbacks(new MyClientCallback());
DEBUG_PRINT("Connecting to ");
DEBUG_PRINTLN(myDevice->getAddress().toString().c_str());
// Connect to the device with timeout
if (!pClient->connect(myDevice, false)) { // false = don't auto-reconnect, we'll handle it manually
DEBUG_PRINTLN("Failed to connect to device");
connected = false;
// Don't delete pClient - NimBLE manages it
pClient = nullptr;
return;
}
DEBUG_PRINTLN("Connected, discovering services...");
// Get the service
pRemoteService = pClient->getService(midiServiceUUID);
if (pRemoteService == nullptr) {
DEBUG_PRINT("Failed to find MIDI service UUID: ");
DEBUG_PRINTLN(midiServiceUUID.toString().c_str());
pClient->disconnect();
connected = false;
return;
}
DEBUG_PRINTLN("Found MIDI service");
// Get the MIDI characteristic (used for both TX and RX)
pMidiTXCharacteristic = pRemoteService->getCharacteristic(midiCharacteristicUUID);
if (pMidiTXCharacteristic == nullptr) {
DEBUG_PRINT("Failed to find MIDI characteristic UUID: ");
DEBUG_PRINTLN(midiCharacteristicUUID.toString().c_str());
pClient->disconnect();
connected = false;
return;
}
DEBUG_PRINTLN("Found MIDI characteristic");
// Subscribe to notifications from the characteristic to receive MIDI data
if (pMidiTXCharacteristic->canNotify()) {
if (!pMidiTXCharacteristic->subscribe(true, [](NimBLERemoteCharacteristic* pNimBLERemoteCharacteristic,
uint8_t* pData,
size_t length,
bool isNotify) {
// Only process MIDI data if we have valid data
if (pData != nullptr && length > 0) {
// Reduce Serial output to minimize latency
// DEBUG_PRINT("MIDI Data received: ");
// for (int i = 0; i < length; i++) {
// DEBUG_PRINTF("0x%02X ", pData[i]);
// }
// DEBUG_PRINTLN();
// Process MIDI data here
processMIDIData(pData, length);
}
})) { // true to enable notifications
DEBUG_PRINTLN("Failed to subscribe for notifications");
} else {
DEBUG_PRINTLN("Successfully subscribed for MIDI notifications");
}
} else {
DEBUG_PRINTLN("MIDI characteristic cannot notify");
}
connected = true;
DEBUG_PRINTLN("Successfully connected to MIDI device and subscribed for notifications");
}
void listenBLEMIDI() {
DEBUG_PRINTLN("Starting BLE Scan for MIDI Controllers...");
// Initialize NimBLE device
NimBLEDevice::init("");
NimBLEScan* pBLEScan = NimBLEDevice::getScan();
// 디바이스 콜백 등록
pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
pBLEScan->setActiveScan(true);
pBLEScan->start(10);
// 스캔 완료 후 myDevice가 설정되었는지 확인
if (myDevice == nullptr) {
DEBUG_PRINTLN("No MIDI device found. Restart and try again.");
return;
}
DEBUG_PRINTLN("Attempting to connect to the device...");
connectToDevice();
}
void processMIDIData(uint8_t* pData, size_t length) {
// Process incoming MIDI data according to MIDI 1.0 and BLE MIDI specifications
// Update MIDI activity indicator
lastMidiActivity = millis();
midiActivity = true;
// BLE MIDI packets may include headers (timestamp, etc.), so we need to parse them
size_t idx = 0;
while (idx < length) {
uint8_t byte = pData[idx+2];
// Check for BLE MIDI header (upper nibble indicates packet type)
// 0x80-0xBF: MIDI data with 13-bit timestamp
// 0xC0-0xDF: MIDI data with 10-bit timestamp
// 0xE0-0xEF: MIDI data with 7-bit timestamp
// 0xF0-0xFF: MIDI data with 0-bit timestamp (no timestamp)
// For simplicity, we'll parse the MIDI message directly
if ((byte & 0x80) == 0x80) { // MIDI status byte
uint8_t status = byte;
uint8_t command = status & 0xF0; // Upper 4 bits: command
uint8_t channel = status & 0x0F; // Lower 4 bits: channel
// DEBUG_PRINTF(" Status: 0x%02X (Command: 0x%02X, Channel: %d)\n", status, command, channel + 1);
switch (command) {
case 0x80: // Note Off
case 0x90: // Note On
case 0xA0: // Polyphonic Aftertouch
case 0xB0: // Control Change
case 0xE0: // Pitch Bend
if (idx + 2 < length) {
uint8_t data1 = pData[idx + 3];
uint8_t data2 = pData[idx + 4];
// Store for display (with bounds checking)
lastMidiChannel = channel + 1;
lastMidiCommand = command;
lastMidiData1 = data1;
lastMidiData2 = data2;
hasMidiData = true;
switch (command) {
case 0x80: // Note Off
// Bounds check note number (0-127)
if (data1 <= 127) {
// DEBUG_PRINTF(" Note Off: Note=%d, Velocity=%d\n", data1, data2);
handleNoteEvent(false, data1, data2, channel);
}
break;
case 0x90: // Note On
// Bounds check note number (0-127)
if (data1 <= 127) {
// DEBUG_PRINTF(" Note On: Note=%d, Velocity=%d\n", data1, data2);
handleNoteEvent(true, data1, data2, channel);
}
break;
case 0xA0: // Polyphonic Aftertouch
// Bounds check note number (0-127) and pressure (0-127)
if (data1 <= 127 && data2 <= 127) {
// DEBUG_PRINTF(" Poly Aftertouch: Note=%d, Pressure=%d\n", data1, data2);
handlePolyAftertouch(data1, data2, channel);
}
break;
case 0xB0: // Control Change
// Bounds check controller number (0-127) and value (0-127)
if (data1 <= 127 && data2 <= 127) {
// DEBUG_PRINTF(" Control Change: Controller=%d, Value=%d\n", data1, data2);
handleControlChange(data1, data2, channel);
}
break;
case 0xE0: // Pitch Bend
// Combine both data bytes (no specific bounds check needed for pitch bend)
uint16_t pitchValue = (data2 << 7) | data1; // Pitch bend combines both data bytes
// DEBUG_PRINTF(" Pitch Bend: Value=%d (LSB=%d, MSB=%d)\n", pitchValue, data1, data2);
lastMidiData1 = pitchValue/1000;
lastMidiData2 = pitchValue%1000;
handlePitchBend(pitchValue, channel);
break;
}
idx += 3; // Move past status + 2 data bytes
} else {
idx++;
}
break;
case 0xC0: // Program Change
case 0xD0: // Channel Aftertouch
if (idx + 1 < length) {
uint8_t data1 = pData[idx + 1];
// Store for display (with bounds checking)
lastMidiChannel = channel + 1;
lastMidiCommand = command;
lastMidiData1 = data1;
lastMidiData2 = 0; // No second data byte
hasMidiData = true;
if (command == 0xC0) {
// Bounds check program number (0-127)
if (data1 <= 127) {
// DEBUG_PRINTF(" Program Change: Program=%d\n", data1);
handleProgramChange(data1, channel);
}
} else {
// Bounds check pressure value (0-127)
if (data1 <= 127) {
// DEBUG_PRINTF(" Channel Aftertouch: Pressure=%d\n", data1);
handleChannelAftertouch(data1, channel);
}
}
idx += 2; // Move past status + 1 data byte
} else {
idx++;
}
break;
default:
// DEBUG_PRINTF(" Unknown MIDI command: 0x%02X\n", command);
idx++;
break;
}
} else {
// Data byte without status - this should not occur in proper MIDI but might in BLE MIDI
// DEBUG_PRINTF(" Unexpected data byte: 0x%02X at position %d\n", byte, idx);
idx++;
}
}
}
// Functions to handle specific MIDI events
void handleNoteEvent(bool isNoteOn, uint8_t note, uint8_t velocity, uint8_t channel) {
// Check if this is the drum channel (MIDI channel 10 = index 9)
if (channel == DRUM_CHANNEL) {
if (isNoteOn && velocity > 0) {
// Process drum note on the drum channel
triggerDrum(note, velocity);
}
// For drum channel, we don't send to the main audio queue
return;
}
// For non-drum channels, handle normally
// DEBUG_PRINTF(" Output: %s - Note: %d, Velocity: %d, Channel: %d\\n",
// isNoteOn ? "NOTE ON" : "NOTE OFF", note, velocity, channel + 1);
// Send note event to audio task via queue
NoteEvent_t noteEvent;
noteEvent.note = note;
noteEvent.velocity = velocity;
noteEvent.isNoteOn = isNoteOn;
noteEvent.channel = channel;
// Ensure the queue exists before trying to send
if (noteEventQueue != NULL) {
// Use non-blocking send with timeout to prevent hanging
if (xQueueSendToBack(noteEventQueue, &noteEvent, pdMS_TO_TICKS(10)) != pdTRUE) {
// If queue is full, try to send with higher priority
DEBUG_PRINTLN("Queue full, note dropped");
}
}
// Here you would implement additional signal output logic if needed
// For example: send to external MIDI interface, control other LEDs, etc.
// Example: Control an LED based on note events
// digitalWrite(ledPin, isNoteOn ? HIGH : LOW);
// Example: Send to hardware serial MIDI (5-pin DIN)
// if (isNoteOn) {
// Serial1.write(0x90 | channel); // Note On for this channel
// Serial1.write(note);
// Serial1.write(velocity);
// } else {
// Serial1.write(0x80 | channel); // Note Off for this channel
// Serial1.write(note);
// Serial1.write(velocity);
// }
}
void handleControlChange(uint8_t controller, uint8_t value, uint8_t channel) {
DEBUG_PRINTF(" Output: Control Change - Controller: %d, Value: %d, Channel: %d\n",
controller, value, channel + 1);
// Check if this is CC65 for waveform mixing
if (controller == 65) { // Custom CC for waveform mixing
// Map value from 0-127 to waveformMixRatio from 1.0 (100% sine) to 0.0 (100% sawtooth)
// Value 0 -> 100% sine, Value 64 -> 50/50, Value 127 -> 100% sawtooth
waveformMixRatio = 1.0f - (value / 127.0f); // Invert so 0=100%sine, 127=100%sawtooth
DEBUG_PRINTF(" Waveform Mix Ratio: %.2f (Square: %.0f%%, Sawtooth: %.0f%%)\n",
waveformMixRatio,
waveformMixRatio * 100.0f,
(1.0f - waveformMixRatio) * 100.0f);
}
}
void handleProgramChange(uint8_t program, uint8_t channel) {
DEBUG_PRINTF(" Output: Program Change - Program: %d, Channel: %d\n",
program, channel + 1);
// Implement your program change handling here
}
void handlePolyAftertouch(uint8_t note, uint8_t pressure, uint8_t channel) {
// DEBUG_PRINTF(" Output: Poly Aftertouch - Note: %d, Pressure: %d, Channel: %d\n",
// note, pressure, channel + 1);
// Find the active note and update its aftertouch value
for (int i = 0; i < MAX_POLYPHONY; i++) {
if (activeNotes[i].active && activeNotes[i].note == note) {
polyAftertouch[i] = pressure;
break;
}
}
}
void handleChannelAftertouch(uint8_t pressure, uint8_t channel) {
// DEBUG_PRINTF(" Output: Channel Aftertouch - Pressure: %d, Channel: %d\n",
// pressure, channel + 1);
// Update the channel aftertouch value
channelAftertouch = pressure;
}
// Function to generate oscillator for kick drum (simplified triangle wave)
float generateKickOsc(float freq, float time) {
float period = 1.0f / freq;
float t_cycle = fmod(time, period);
// 톱니파: -1 ~ +1 범위
return (2.0f * t_cycle / period) - 1.0f;
}
// Pitch envelope for kick drum
float kickPitchEnv(float t) {
// 빠른 Attack 후 Exp decay
return kickTone + kickEnvDepth * expf(-t * 4.0f / kickDecay);
}
// Amplitude envelope for kick drum
float kickAmpEnv(float t) {
if (t > kickDecay) return 0.0f;
return expf(-t * 4.0f / kickDecay);
}
// Noise envelope for kick drum
float kickNoiseEnv(float t) {
if (t < 0.01f)
return (float(random(-1000, 1000)) / 1000.0f) * kickNoiseAmt * (1.0f - t / 0.01f); // 어택 잡음
return 0.0f;
}
// Generate TR-909 style kick drum sample
float generateTR909KickSample(float time) {
float env = kickAmpEnv(time);
float pEnv = kickPitchEnv(time);
float osc = generateKickOsc(pEnv, time);
float noise = kickNoiseEnv(time);
return env * osc + noise;
}
// Function to trigger drum sound
void triggerDrum(int note, uint8_t velocity) {
// Process the drum note
if (note >= 35 && note <= 51) {
int index = note - 35;
DEBUG_PRINTF("Attempting to trigger drum note: %d (velocity: %d)\n", note, velocity);
// Find a free drum voice
int freeVoice = -1;
for (int i = 0; i < MAX_DRUM_VOICES; i++) {
if (!drumVoices[i].active) {
freeVoice = i;
DEBUG_PRINTF("Found free drum voice at index: %d\n", i);
break;
}
}
// If no free voice, use the first one (simple voice stealing)
if (freeVoice == -1) {
freeVoice = 0;
DEBUG_PRINTF("No free drum voice found, using voice 0 (voice stealing)\n");
}
// Initialize the drum voice
drumVoices[freeVoice].note = note;
drumVoices[freeVoice].amplitude = (float)velocity / 127.0f;
drumVoices[freeVoice].phase = 0.0f;
drumVoices[freeVoice].envelopePhase = 0.0f;
drumVoices[freeVoice].startTime = millis();
drumVoices[freeVoice].lastOutputSample = 0.0f;
drumVoices[freeVoice].active = true;
// Update active drum count
if (!drumVoices[freeVoice].active) { // This condition is always false since we just set it to true
activeDrumCount++;
} else {
// Since we just made it active, increment the count
activeDrumCount++;
DEBUG_PRINTF("Active drum count incremented to: %d\n", activeDrumCount);
}
// Ensure audio task is running when drum is triggered
audioPlaying = true;
// Debug: Print which drum instrument is triggered
DEBUG_PRINTF("Triggered drum: %s (voice %d)\n", DRUM_INSTRUMENT_NAMES[index], freeVoice);
} else {
DEBUG_PRINTF("Drum note %d is out of range [35-51]\n", note);
}
}
void handlePitchBend(uint16_t value, uint8_t channel) {
// Convert 14-bit pitch bend value (0-16383) to signed value (-8192 to 8191)
// Center value is 8192 (0x2000), so subtract 8192 to get signed value
int16_t signedValue = (int16_t)value - 8192;
// DEBUG_PRINTF(" Output: Pitch Bend - Value: %d (signed: %d), Channel: %d\n",
// value, signedValue, channel + 1);
// Update the global pitch bend value
pitchBendValue = signedValue;
// Apply pitch bend to all currently active notes
for (int i = 0; i < MAX_POLYPHONY; i++) {
if (activeNotes[i].active) {
float baseFrequency = noteToFrequency(activeNotes[i].note);
float pitchBendSemitones = (pitchBendValue / 8192.0f) * pitchBendRange;
float pitchBendFactor = pow(2.0f, pitchBendSemitones / 12.0f);
activeNotes[i].frequency = baseFrequency * pitchBendFactor;
}
}
}
void setup() {
Serial.begin(115200);
u8g2.begin();
u8g2.setContrast(255); // set contrast to maximum
u8g2.setBusClock(400000); //400kHz I2C
u8g2.setFont(u8g2_font_6x10_mr);
// Initialize LED pin
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW); // Start with LED off
// Initialize drum voices
initDrumVoices();
// Initialize I2S for PCM5102A
initI2S();
// Create queue for MIDI note events
noteEventQueue = xQueueCreate(10, sizeof(NoteEvent_t)); // Note event structure
// Initialize active notes array
for (int i = 0; i < MAX_POLYPHONY; i++) {
activeNotes[i].active = false;
activeNotes[i].note = 0;
activeNotes[i].velocity = 0;
activeNotes[i].frequency = 0.0f;
}
// Initialize pitch bend and aftertouch values
pitchBendValue = 0;
channelAftertouch = 0;
for (int i = 0; i < MAX_POLYPHONY; i++) {
polyAftertouch[i] = 0;
}
// Initialize audio quality variables
waveformType = 1; // Default to sine wave
// Create audio task with higher priority
xTaskCreate(audioTask, "AudioTask", 4096, NULL, 10, &audioTaskHandle);
// Initialize NimBLE
NimBLEDevice::init("");
// Initialize MIDI data display variables
hasMidiData = false;
}
void loop() {
unsigned long currentMillis = millis();
// marquee 0.4초 간격 타이머
if (currentMillis - lastMarqMillis >= 400) {
lastMarqMillis = currentMillis;
if (connected && connectedDeviceName.length() > 11) {
marqPos++;
if (marqPos >= connectedDeviceName.length()) {
marqPos = 0;
}
}
}
// Check for serial input for debugging
if (Serial.available() > 0) {
String input = Serial.readStringUntil('\n');
input.trim(); // Remove any trailing whitespace
if (input == "test") {
playTestSequence();
} else if (input == "wave_sine") {
waveformType = 1;
DEBUG_PRINTLN("Waveform set to sine");
} else if (input == "wave_sawtooth") {
waveformType = 0;
DEBUG_PRINTLN("Waveform set to sawtooth");
} else if (input.startsWith("mix ")) {
// Parse mix command: "mix <value>" where value is 0-127
String mixValueStr = input.substring(4); // Remove "mix " part
int mixValue = mixValueStr.toInt();
if (mixValue >= 0 && mixValue <= 127) {
waveformMixRatio = 1.0f - (mixValue / 127.0f);
DEBUG_PRINTF("Waveform Mix Ratio set to: %.2f (Square: %.0f%%, Sawtooth: %.0f%%)\n",
waveformMixRatio, waveformMixRatio * 100.0f, (1.0f - waveformMixRatio) * 100.0f);
} else {
DEBUG_PRINTLN("Invalid mix value. Use 0-127 (0=sine, 64=50/50, 127=sawtooth)");
}
} else if (input == "status") {
DEBUG_PRINTLN("=== System Status ===");
DEBUG_PRINTF("Active Notes: %d/%d\n", activeNoteCount, MAX_POLYPHONY);
DEBUG_PRINTF("Audio Playing: %s\n", audioPlaying ? "Yes" : "No");
DEBUG_PRINTF("Current Note: %d\n", currentNote);
DEBUG_PRINTF("Current Velocity: %d\n", currentVelocity);
DEBUG_PRINTF("Current Frequency: %.2f Hz\n", currentFrequency);
DEBUG_PRINTF("Pitch Bend Value: %d\n", pitchBendValue);
if (useWaveformMixing) {
DEBUG_PRINTF("Waveform Mixing: Enabled\n");
DEBUG_PRINTF("Square/Sawtooth Ratio: %.2f (Square: %.0f%%, Sawtooth: %.0f%%)\n",
waveformMixRatio, waveformMixRatio * 100.0f, (1.0f - waveformMixRatio) * 100.0f);
} else {
DEBUG_PRINTF("Waveform Type: %s\n", waveformType == 0 ? "Sawtooth" : "Square");
}
DEBUG_PRINTLN("Active Voices:");
for (int i = 0; i < MAX_POLYPHONY; i++) {
if (activeNotes[i].active) {
DEBUG_PRINTF(" Voice %d: Note %d, Freq %.2f Hz\n", i, activeNotes[i].note, activeNotes[i].frequency);
}
}
} else if (input == "voices") {
DEBUG_PRINTF("Current Polyphony: %d/%d\n", activeNoteCount, MAX_POLYPHONY);
for (int i = 0; i < MAX_POLYPHONY; i++) {
if (activeNotes[i].active) {
DEBUG_PRINTF(" #%d: Note %d (%.2f Hz), Vel %d\n", i, activeNotes[i].note, activeNotes[i].frequency, activeNotes[i].velocity);
} else {
DEBUG_PRINTF(" #%d: Inactive\n", i);
}
}
} else if (input == "reset") {
// Reset all audio parameters
audioPlaying = false;
currentNote = 0;
currentVelocity = 100;
currentFrequency = 440.0f;
currentPhase = 0.0f;
// Reset all active notes
for (int i = 0; i < MAX_POLYPHONY; i++) {
activeNotes[i].active = false;
activeNotes[i].note = 0;
activeNotes[i].velocity = 0;
activeNotes[i].frequency = 0.0f;
notePhase[i] = 0.0f;
}
activeNoteCount = 0;
// Reset pitch bend and aftertouch
pitchBendValue = 0;
channelAftertouch = 0;
for (int i = 0; i < MAX_POLYPHONY; i++) {
polyAftertouch[i] = 0;
}
DEBUG_PRINTLN("Audio system reset");
}
}
// Update MIDI activity state
updateMidiActivity();
// Update LED blinking
updateLEDBlinking();
// 연결이 끊어지면 재연결 시도
if (myDevice == nullptr || pClient == nullptr) {
updateU8G2("no device"); // 기본 문자열 표시
} else {
updateU8G2(connectedDeviceName.c_str());
}
if (!connected) {
USER_PRINTLN("Disconnected. Scanning for a new device to connect...");
// Clean up any existing client object reference (NimBLE manages the object)
pClient = nullptr;
// Restart scanning for devices
listenBLEMIDI(); // 스캔 및 재연결 재시도
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment