Last active
October 26, 2025 16:52
-
-
Save acidsound/37051a0ce90ce94918bccebe112af452 to your computer and use it in GitHub Desktop.
polypony sine/sawtooth synth - add realtime blender + drum synth
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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, ¬eEvent, 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, ¬eEvent, portMAX_DELAY); | |
| } | |
| delay(100); // Play for 100ms | |
| // E4 = MIDI note 64 (329.63 Hz) | |
| noteEvent.note = 64; | |
| if (noteEventQueue != NULL) { | |
| xQueueSendToBack(noteEventQueue, ¬eEvent, portMAX_DELAY); | |
| } | |
| delay(100); // Play for 100ms | |
| // G4 = MIDI note 67 (392.00 Hz) | |
| noteEvent.note = 67; | |
| if (noteEventQueue != NULL) { | |
| xQueueSendToBack(noteEventQueue, ¬eEvent, 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, ¬eEvent, 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, ¬eEvent, 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