diff --git a/AGENTS.md b/AGENTS.md index 27c32e8..c6a1c4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ This repository contains a Python synthesizer and its accompanying tests. - Type hints encouraged - Error handling: use try/except with specific exceptions - Thread safety: use locks when modifying shared resources +- Prefer modern Python features (dataclasses with `slots`, structural pattern matching, type aliases) when adding or updating code. ## Programmatic Checks @@ -40,7 +41,6 @@ This repository contains a Python synthesizer and its accompanying tests. - `gui_qt.py` - PyQt-based graphical user interface - `config.py` - Configuration management and settings - `patch.py` - Sound preset management (save/load patches) - - `input.py` - Keyboard input handling and note mapping ### Audio Effects & Processing - `adsr.py` - Attack/Decay/Sustain/Release envelope generator @@ -74,4 +74,4 @@ This repository contains a Python synthesizer and its accompanying tests. ## Pull Requests Summaries in pull request bodies should briefly describe the implemented changes -and mention the result of running the test suite. \ No newline at end of file +and mention the result of running the test suite. diff --git a/docs/keyboard_to_midi_plan.md b/docs/keyboard_to_midi_plan.md new file mode 100644 index 0000000..fbba5ae --- /dev/null +++ b/docs/keyboard_to_midi_plan.md @@ -0,0 +1,71 @@ +# Keyboard-to-MIDI Translator Plan + +## Overview +- Build a dedicated module that listens to QWERTY keyboard events and emits MIDI-style note messages so downstream components can treat it like any other MIDI instrument. +- Decouple GUI and controller code from the current ad-hoc keyboard model to simplify future input modes (real MIDI controllers, automation, etc.). + +## Design Goals +- Keep keyboard-to-note mapping explicit and easily swappable. +- Emit canonical MIDI `note_on`/`note_off` events with velocity and timestamp metadata. +- Maintain compatibility with mono/poly modes, arpeggiator, transpose, and global config locks. +- Provide a clean integration layer so the rest of the synth only handles MIDI events. + +## Proposed Architecture +- `qwerty_synth/keyboard_midi.py` will host the new functionality. +- Core pieces: + - `KeyboardMidiTranslator`: wraps `pynput.keyboard.Listener`, handles key state, octave/semitone shifts, and maps QWERTY keys to MIDI note numbers. + - `MidiEvent`: lightweight dataclass bundling `type` (`note_on`/`note_off`), `note`, `velocity`, `channel`, and `timestamp`. + - `MidiEventDispatcher`: pluggable callback (default: controller integration) invoked sequentially from translator threads while guarding shared state with `config.notes_lock`. +- Translator responsibilities: + - Maintain pressed key set to avoid duplicate `note_on` spam and to support mono note priority logic before handing notes to the controller. + - Emit transpose or mode-change commands by publishing control callbacks instead of directly mutating `config` (e.g., dedicated dispatcher hook or new `controller.apply_transpose_delta`). + - Allow velocity overrides (initially constant) and expose hooks for future features (e.g., velocity from key press duration). +- Event flow: QWERTY key → translator produces MIDI event → dispatcher maps to `controller.handle_midi_message` → controller converts to oscillator lifecycle operations in `synth`. + +## Integration Strategy +- Controller layer: + - Add `handle_midi_message(message: MidiEvent)` to centralize `note_on`/`note_off` handling, reuse existing helper `play_midi_note` for note-on logic, and add a symmetric release path that operates via oscillator keys rather than raw characters. + - Consolidate mono-mode tracking inside controller (possibly using a queue or `MonoVoiceManager`) so the translator no longer manipulates `config.active_notes` directly. +- GUI / application bootstrap: + - Replace imports of `qwerty_synth.input` with the new translator. + - Update GUI toggles (octave buttons, arpeggiator, mono mode) to call controller helpers or translator setters rather than mutating module globals. + - Ensure controller provides functions the GUI can call when the user changes transpose so translator state stays in sync. +- Configuration adjustments: + - Move `mono_pressed_keys` and any keyboard-specific artifacts into translator-owned state. + - Keep `octave_offset`, `semitone_offset`, and locks in `config`, but expose controller helpers that the translator uses to read/write the values under lock. + +## Legacy Keyboard Model Removal +- ✅ Removed `qwerty_synth/input.py` after migrating all call sites to the translator/controller stack. +- ✅ Replaced the old input tests with controller/translator coverage and smoke tests. +- ✅ Routed exit behaviour through dispatcher events and eliminated legacy globals. +- Ongoing: keep an eye out for stale references during future refactors. + +## Testing & Validation +- Unit tests for translator: + - Key-to-MIDI mapping integrity, including octave/semitone shifts and boundary checks. + - Mono/poly behavior via injected mock dispatcher capturing emitted events. + - Arpeggiator enablement path ensures appropriate events (possibly beat-tracked) are pushed. +- Integration tests: + - Controller `handle_midi_message` generates/release oscillators correctly. + - GUI smoke test ensuring translator bootstrap occurs without raising and exits cleanly when requested. +- Manual verification checklist: + - Run synth and confirm QWERTY input still plays notes. + - Confirm mono glide and octave switches function. + - Validate arpeggiator receives held notes. + +## Open Assumptions +- `pynput` remains our keyboard listener and is acceptable for the new module. +- We will keep using `mido` message semantics (note numbers 0–127, velocity 0–127 scaled internally) without introducing a third-party virtual MIDI device. +- Mono voice priority can stay "last pressed" for now; no need for configurable priority schemes during this refactor. +- Escape-to-exit behavior can be mediated through the dispatcher without additional UI prompts. +- Tests can be refactored in place without large fixture overhauls (current mocking strategy stays workable). + +## Current Status +- ✅ Implemented `qwerty_synth/keyboard_midi.py` with `KeyboardMidiTranslator`, `MidiEvent`, transpose controls, system-exit signaling, duplicate key suppression, and safe config access. +- ✅ Added translator-focused unit tests (`tests/test_keyboard_midi.py`) and reshaped controller tests (`tests/test_input.py`) around the new API boundary. +- ✅ Integrated the translator with the controller and GUI bootstrap: `controller.handle_midi_message` now manages keyboard-driven mono/poly voice allocation and transpose events, while `gui_qt` instantiates the translator and routes system-exit requests through Qt-safe callbacks. +- ✅ Refactored GUI octave/semitone/mono controls to go through controller helpers and introduced `tests/test_keyboard_integration.py` smoke coverage for translator→controller flows. + +## Next Implementation Steps +- **Next Step — Harden integration with broader regression coverage:** + Add headless GUI smoke tests or extended translator/controller scenarios (including arpeggiator + mono interactions) and tighten teardown hooks so repeated sessions leave no dangling listeners. diff --git a/qwerty_synth/controller.py b/qwerty_synth/controller.py index e4cc48d..0805d29 100644 --- a/qwerty_synth/controller.py +++ b/qwerty_synth/controller.py @@ -5,12 +5,91 @@ import mido from qwerty_synth import config +from qwerty_synth.keyboard_midi import MidiEvent from qwerty_synth.synth import Oscillator # Counter to generate unique keys for notes in polyphonic mode _note_counter = 0 +# Keyboard event bookkeeping for translator-driven input +_keyboard_active_note_keys: dict[int, str] = {} +_keyboard_pressed_notes: list[int] = [] +_keyboard_note_velocities: dict[int, float] = {} + + +def _velocity_to_scalar(velocity: int | float | None) -> float: + """Convert MIDI velocity (0-127) into a 0.0-1.0 scalar.""" + if velocity is None: + return 1.0 + + if isinstance(velocity, float): + return max(0.0, min(1.0, velocity)) + + return max(0.0, min(1.0, velocity / 127.0)) + + +def _sync_mono_pressed_notes() -> None: + """Keep legacy mono pressed list in sync for GUI interactions.""" + config.mono_pressed_keys = list(_keyboard_pressed_notes) + + +def get_octave_offset() -> int: + """Return the current octave offset in semitones.""" + with config.notes_lock: + return config.octave_offset + + +def get_semitone_offset() -> int: + """Return the current semitone transpose offset.""" + with config.notes_lock: + return config.semitone_offset + + +def _get_arpeggiator_module(): + try: + from qwerty_synth import arpeggiator as arpeggiator_module # pylint: disable=import-outside-toplevel + except ImportError: + return None + + return arpeggiator_module + + +def _clear_keyboard_state_unlocked() -> None: + _keyboard_active_note_keys.clear() + _keyboard_pressed_notes.clear() + _keyboard_note_velocities.clear() + config.mono_pressed_keys.clear() + + +def reset_keyboard_state() -> None: + """Clear cached keyboard tracking structures.""" + with config.notes_lock: + _clear_keyboard_state_unlocked() + + +def update_mono_mode(enabled: bool) -> None: + """Toggle mono mode and clear state safely.""" + with config.notes_lock: + config.mono_mode = enabled + config.active_notes.clear() + _clear_keyboard_state_unlocked() + + +def set_octave(value: int) -> None: + """Set octave offset from a GUI control (value expressed in octaves).""" + semitone_offset = value * 12 + with config.notes_lock: + minimum = 12 * config.octave_min + maximum = 12 * config.octave_max + config.octave_offset = max(minimum, min(maximum, semitone_offset)) + + +def set_semitone(value: int) -> None: + """Set semitone offset from GUI control input.""" + with config.notes_lock: + config.semitone_offset = max(config.semitone_min, min(config.semitone_max, value)) + def play_note(freq, duration=0.5, velocity=1.0): """ @@ -151,6 +230,137 @@ def play_next(seq, index): play_next(sequence, 0) +def handle_midi_message(event: MidiEvent) -> None: + """Handle MIDI-style events emitted by the keyboard translator.""" + if event.event_type == 'note_on': + if event.note is None: + return + if event.velocity <= 0: + _handle_keyboard_note_off(event.note) + return + _handle_keyboard_note_on(event.note, event.velocity) + elif event.event_type == 'note_off': + if event.note is None: + return + _handle_keyboard_note_off(event.note) + elif event.event_type == 'transpose': + delta = int(event.payload.get('delta', 0)) if event.payload else 0 + if delta: + apply_transpose_delta(delta) + + +def apply_transpose_delta(delta: int) -> bool: + """Apply a transpose delta (in semitones) while respecting configuration bounds.""" + with config.notes_lock: + new_offset = config.octave_offset + delta + minimum = 12 * config.octave_min + maximum = 12 * config.octave_max + + if not minimum <= new_offset <= maximum: + return False + + config.octave_offset = new_offset + + direction = 'up' if delta > 0 else 'down' + print(f'Octave {direction}: {config.octave_offset // 12:+}') + + arpeggiator_module = _get_arpeggiator_module() + if config.arpeggiator_enabled and arpeggiator_module and arpeggiator_module.arpeggiator_instance: + arpeggiator_module.arpeggiator_instance.clear_notes() + + return True + + +def _handle_keyboard_note_on(midi_note: int, velocity: int) -> None: + velocity_scalar = _velocity_to_scalar(velocity) + + with config.notes_lock: + if midi_note in _keyboard_pressed_notes: + return + + _keyboard_pressed_notes.append(midi_note) + _keyboard_note_velocities[midi_note] = velocity_scalar + _sync_mono_pressed_notes() + + arpeggiator_module = _get_arpeggiator_module() + if config.arpeggiator_enabled and arpeggiator_module and arpeggiator_module.arpeggiator_instance: + arpeggiator_module.arpeggiator_instance.add_note(midi_note) + if not config.arpeggiator_sustain_base: + return + + freq = midi_to_freq(midi_note) + + if config.mono_mode: + for existing_note, key_name in list(_keyboard_active_note_keys.items()): + if key_name == 'mono': + _keyboard_active_note_keys.pop(existing_note, None) + + osc = config.active_notes.get('mono') + + if osc is None or osc.released: + osc = Oscillator(freq, config.waveform_type) + osc.key = 'mono' + config.active_notes['mono'] = osc + else: + osc.target_freq = freq + osc.lfo_env_time = 0.0 + + osc.velocity = velocity_scalar + osc.released = False + osc.env_time = 0.0 + _keyboard_active_note_keys[midi_note] = 'mono' + else: + if midi_note in _keyboard_active_note_keys: + return + + key = f'keyboard_{midi_note}' + osc = Oscillator(freq, config.waveform_type) + osc.key = key + osc.velocity = velocity_scalar + config.active_notes[key] = osc + _keyboard_active_note_keys[midi_note] = key + + +def _handle_keyboard_note_off(midi_note: int) -> None: + with config.notes_lock: + if midi_note in _keyboard_pressed_notes: + _keyboard_pressed_notes.remove(midi_note) + _sync_mono_pressed_notes() + + _keyboard_note_velocities.pop(midi_note, None) + + arpeggiator_module = _get_arpeggiator_module() + if config.arpeggiator_enabled and arpeggiator_module and arpeggiator_module.arpeggiator_instance: + arpeggiator_module.arpeggiator_instance.remove_note(midi_note) + + if config.mono_mode: + _keyboard_active_note_keys.pop(midi_note, None) + if _keyboard_pressed_notes: + next_note = _keyboard_pressed_notes[-1] + freq = midi_to_freq(next_note) + osc = config.active_notes.get('mono') + if osc is not None: + osc.target_freq = freq + osc.lfo_env_time = 0.0 + osc.velocity = _keyboard_note_velocities.get(next_note, osc.velocity) + _keyboard_active_note_keys[next_note] = 'mono' + else: + osc = config.active_notes.get('mono') + if osc is not None: + osc.released = True + osc.env_time = 0.0 + osc.lfo_env_time = 0.0 + _keyboard_active_note_keys.pop(midi_note, None) + return + + key = _keyboard_active_note_keys.pop(midi_note, None) + if key and key in config.active_notes: + osc = config.active_notes[key] + osc.released = True + osc.env_time = 0.0 + osc.lfo_env_time = 0.0 + + def play_midi_file(midi_file_path, tempo_scale=1.0): """ Play a MIDI file using the synthesizer. diff --git a/qwerty_synth/gui_qt.py b/qwerty_synth/gui_qt.py index 08c2033..656aa08 100644 --- a/qwerty_synth/gui_qt.py +++ b/qwerty_synth/gui_qt.py @@ -20,11 +20,11 @@ from qwerty_synth import config from qwerty_synth import adsr from qwerty_synth import synth -from qwerty_synth import input as kb_input +from qwerty_synth import controller from qwerty_synth import filter from qwerty_synth.delay import DIV2MULT +from qwerty_synth.keyboard_midi import KeyboardMidiTranslator, MidiEvent from qwerty_synth.step_sequencer import StepSequencer -from qwerty_synth.controller import play_midi_file from qwerty_synth import record from qwerty_synth import patch from qwerty_synth.arpeggiator import Arpeggiator @@ -33,6 +33,9 @@ # Global variable to hold reference to the GUI instance gui_instance = None +# Track the keyboard translator so we can shut it down cleanly +keyboard_translator: KeyboardMidiTranslator | None = None + class SynthGUI(QMainWindow): """GUI for controlling QWERTY Synth parameters using PyQt.""" @@ -171,13 +174,13 @@ def setup_ui(self): self.octave_dial = QDial() self.octave_dial.setRange(config.octave_min, config.octave_max) - self.octave_dial.setValue(config.octave_offset // 12) + self.octave_dial.setValue(controller.get_octave_offset() // 12) self.octave_dial.valueChanged.connect(self.update_octave) self.octave_dial.setNotchesVisible(True) self.octave_dial.setToolTip("Transpose by octaves (Z/X keys)") transpose_layout.addWidget(self.octave_dial, 1, 0) - self.octave_label = QLabel(f"{config.octave_offset // 12:+d}") + self.octave_label = QLabel(f"{controller.get_octave_offset() // 12:+d}") self.octave_label.setAlignment(Qt.AlignCenter) transpose_layout.addWidget(self.octave_label, 2, 0) @@ -188,13 +191,13 @@ def setup_ui(self): self.semitone_dial = QDial() self.semitone_dial.setRange(config.semitone_min, config.semitone_max) - self.semitone_dial.setValue(config.semitone_offset) + self.semitone_dial.setValue(controller.get_semitone_offset()) self.semitone_dial.valueChanged.connect(self.update_semitone) self.semitone_dial.setNotchesVisible(True) self.semitone_dial.setToolTip("Transpose by semitones") transpose_layout.addWidget(self.semitone_dial, 1, 1) - self.semitone_label = QLabel(f"{config.semitone_offset:+d}") + self.semitone_label = QLabel(f"{controller.get_semitone_offset():+d}") self.semitone_label.setAlignment(Qt.AlignCenter) transpose_layout.addWidget(self.semitone_label, 2, 1) @@ -1267,21 +1270,21 @@ def update_plots(self): break # Check if octave has changed and update GUI if needed - current_octave_text = f"{config.octave_offset // 12:+d}" + current_octave_text = f"{controller.get_octave_offset() // 12:+d}" if self.octave_label.text() != current_octave_text: self.octave_label.setText(current_octave_text) # Update dial without triggering signals self.octave_dial.blockSignals(True) - self.octave_dial.setValue(config.octave_offset // 12) + self.octave_dial.setValue(controller.get_octave_offset() // 12) self.octave_dial.blockSignals(False) # Check if semitone has changed and update GUI if needed - current_semitone_text = f"{config.semitone_offset:+d}" + current_semitone_text = f"{controller.get_semitone_offset():+d}" if self.semitone_label.text() != current_semitone_text: self.semitone_label.setText(current_semitone_text) # Update slider without triggering signals self.semitone_dial.blockSignals(True) - self.semitone_dial.setValue(config.semitone_offset) + self.semitone_dial.setValue(controller.get_semitone_offset()) self.semitone_dial.blockSignals(False) # Check if filter cutoff has changed and update GUI if needed @@ -1634,12 +1637,8 @@ def update_filter_env_amount(self, value): def update_mono_mode(self, state): """Update the mono mode setting.""" - config.mono_mode = state + controller.update_mono_mode(state) self.mono_button.setChecked(state) - # Clear active notes to prevent stuck notes when switching modes - with config.notes_lock: - config.active_notes.clear() - config.mono_pressed_keys.clear() def update_glide_time(self, value): """Update the glide time setting.""" @@ -1840,12 +1839,12 @@ def sync_sequencer_bpm(self, bpm): def decrease_octave(self): """Decrease the octave by one.""" - if config.octave_offset > 12 * config.octave_min: - config.octave_offset -= 12 - self.octave_label.setText(f"{config.octave_offset // 12:+d}") + if controller.get_octave_offset() > 12 * config.octave_min: + controller.apply_transpose_delta(-12) + self.octave_label.setText(f"{controller.get_octave_offset() // 12:+d}") # Update slider without triggering the signal self.octave_dial.blockSignals(True) - self.octave_dial.setValue(config.octave_offset // 12) + self.octave_dial.setValue(controller.get_octave_offset() // 12) self.octave_dial.blockSignals(False) # Clear arpeggiator when transpose changes @@ -1854,12 +1853,12 @@ def decrease_octave(self): def increase_octave(self): """Increase the octave by one.""" - if config.octave_offset < 12 * config.octave_max: - config.octave_offset += 12 - self.octave_label.setText(f"{config.octave_offset // 12:+d}") + if controller.get_octave_offset() < 12 * config.octave_max: + controller.apply_transpose_delta(12) + self.octave_label.setText(f"{controller.get_octave_offset() // 12:+d}") # Update slider without triggering the signal self.octave_dial.blockSignals(True) - self.octave_dial.setValue(config.octave_offset // 12) + self.octave_dial.setValue(controller.get_octave_offset() // 12) self.octave_dial.blockSignals(False) # Clear arpeggiator when transpose changes @@ -1868,9 +1867,8 @@ def increase_octave(self): def update_octave(self, value): """Update the octave from dial value.""" - # Convert dial value to offset (x12 semitones per octave) - config.octave_offset = value * 12 - self.octave_label.setText(f"{value:+d}") + controller.set_octave(value) + self.octave_label.setText(f"{controller.get_octave_offset() // 12:+d}") # Clear arpeggiator when transpose changes if hasattr(self, 'arpeggiator') and self.arpeggiator: @@ -1878,8 +1876,8 @@ def update_octave(self, value): def update_semitone(self, value): """Update the semitone transpose from dial value.""" - config.semitone_offset = value - self.semitone_label.setText(f"{value:+d}") + controller.set_semitone(value) + self.semitone_label.setText(f"{controller.get_semitone_offset():+d}") # Clear arpeggiator when transpose changes if hasattr(self, 'arpeggiator') and self.arpeggiator: @@ -2040,7 +2038,7 @@ def _play_midi_thread(self, file_path, tempo_scale): config.midi_playback_active = True # Play the MIDI file - play_midi_file(file_path, tempo_scale) + controller.play_midi_file(file_path, tempo_scale) # Don't immediately reset state as the playback runs asynchronously # It will be reset when the playback completes or is stopped @@ -2398,6 +2396,8 @@ def filter_patches(self, search_text): def start_gui(): """Start the GUI and synth components.""" + global gui_instance, keyboard_translator # pylint: disable=global-statement + app = QApplication(sys.argv) # Apply QDarkStyle dark theme @@ -2412,7 +2412,6 @@ def start_gui(): gui = SynthGUI() # Store global reference to GUI for signal handling - global gui_instance gui_instance = gui # Set up signal handler for Ctrl+C @@ -2430,16 +2429,29 @@ def signal_handler(sig, frame): timer.start(500) # Check for signals every 500ms timer.timeout.connect(lambda: None) # Wake up Python interpreter regularly - # Share the GUI instance with the input module - kb_input.gui_instance = gui - # Start audio using synth entry points if not synth.start_audio(): QMessageBox.critical(gui, "Audio Error", "Failed to start audio. Please check your audio system.") sys.exit(1) - # Start keyboard input handling in a separate thread - kb_input.start_keyboard_input() + controller.reset_keyboard_state() + + def dispatch_keyboard_event(event: MidiEvent) -> None: + if event.event_type == 'system_exit': + print('Exiting...') + + def close_gui(): + if gui_instance is not None: + gui_instance.close() + app.quit() + + QTimer.singleShot(0, close_gui) + return + + controller.handle_midi_message(event) + + keyboard_translator = KeyboardMidiTranslator(dispatcher=dispatch_keyboard_event) + keyboard_translator.start() # Start Qt event loop try: @@ -2451,6 +2463,9 @@ def signal_handler(sig, frame): finally: # Clean up using synth entry points synth.stop_audio() + if keyboard_translator is not None: + keyboard_translator.stop() + keyboard_translator = None if __name__ == "__main__": diff --git a/qwerty_synth/input.py b/qwerty_synth/input.py deleted file mode 100644 index 425e254..0000000 --- a/qwerty_synth/input.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Keyboard input handling for QWERTY Synth.""" - -import threading -from pynput import keyboard -import sounddevice as sd - -from qwerty_synth import config -from qwerty_synth import synth -from qwerty_synth import controller # Import the controller module -from qwerty_synth import arpeggiator - -# Add a reference to store the GUI instance -gui_instance = None - -# MIDI note mapping (instead of direct frequency mapping) -key_midi_map = { - 'a': 60, # C4 (middle C) - 'w': 61, # C#4 - 's': 62, # D4 - 'e': 63, # D#4 - 'd': 64, # E4 - 'f': 65, # F4 - 't': 66, # F#4 - 'g': 67, # G4 - 'y': 68, # G#4 - 'h': 69, # A4 (440Hz) - 'u': 70, # A#4 - 'j': 71, # B4 - 'k': 72, # C5 - 'o': 73, # C#5 - 'l': 74, # D5 - 'p': 75, # D#5 - ';': 76, # E5 - "'": 77, # F5 -} - - -def on_press(key): - """Handle key press events.""" - global gui_instance - - try: - k = key.char.lower() - - if k in key_midi_map: - midi_note = key_midi_map[k] + config.octave_offset + config.semitone_offset - freq = controller.midi_to_freq(midi_note) - - with config.notes_lock: - # Track the key press for mono mode (always track, regardless of arpeggiator) - if k not in config.mono_pressed_keys: - config.mono_pressed_keys.append(k) - - # Add note to arpeggiator if enabled - if config.arpeggiator_enabled and arpeggiator.arpeggiator_instance: - arpeggiator.arpeggiator_instance.add_note(midi_note) - # When arpeggiator is enabled and sustain_base is False, don't create sustained oscillators - # When sustain_base is True, continue to create sustained notes alongside arpeggio - if not config.arpeggiator_sustain_base: - return - - if config.mono_mode: - # In mono mode, create or update oscillator directly - if 'mono' not in config.active_notes or config.active_notes['mono'].released: - # No current note or released note - create new oscillator - osc = synth.Oscillator(freq, config.waveform_type) - osc.key = 'mono' - config.active_notes['mono'] = osc - else: - # Update existing oscillator's target frequency - config.active_notes['mono'].target_freq = freq - else: - # Polyphonic mode - if k not in config.active_notes: - osc = synth.Oscillator(freq, config.waveform_type) - osc.key = k - config.active_notes[k] = osc - - elif k == 'z' and config.octave_offset > 12 * config.octave_min: - config.octave_offset -= 12 - print(f'Octave down: {config.octave_offset // 12:+}') - - # Clear arpeggiator when transpose changes - if config.arpeggiator_enabled and arpeggiator.arpeggiator_instance: - arpeggiator.arpeggiator_instance.clear_notes() - - elif k == 'x' and config.octave_offset < 12 * config.octave_max: - config.octave_offset += 12 - print(f'Octave up: {config.octave_offset // 12:+}') - - # Clear arpeggiator when transpose changes - if config.arpeggiator_enabled and arpeggiator.arpeggiator_instance: - arpeggiator.arpeggiator_instance.clear_notes() - - except AttributeError: - if key == keyboard.Key.esc: - print('Exiting...') - - sd.stop() - - # Close the GUI if it exists - use thread-safe method - if gui_instance is not None: - # Schedule the close operation on the main GUI thread - try: - from PyQt5.QtCore import QTimer - QTimer.singleShot(0, gui_instance.close) - except ImportError: - # Fallback if PyQt5 is not available - gui_instance.close() - - return False - - -def on_release(key): - """Handle key release events.""" - try: - k = key.char.lower() - - with config.notes_lock: - # Remove key from mono_pressed_keys list if it exists - if k in config.mono_pressed_keys: - config.mono_pressed_keys.remove(k) - - # Remove note from arpeggiator if enabled - if k in key_midi_map and config.arpeggiator_enabled and arpeggiator.arpeggiator_instance: - midi_note = key_midi_map[k] + config.octave_offset + config.semitone_offset - arpeggiator.arpeggiator_instance.remove_note(midi_note) - - if k in config.active_notes or (config.mono_mode and 'mono' in config.active_notes): - if not config.mono_mode: - # Regular polyphonic mode - release the specific note - if k in config.active_notes: - config.active_notes[k].released = True - config.active_notes[k].env_time = 0.0 - config.active_notes[k].lfo_env_time = 0.0 # Reset LFO envelope time - else: - # Mono mode - handle differently - if not config.mono_pressed_keys: - # No keys pressed - release the mono oscillator - if 'mono' in config.active_notes: - config.active_notes['mono'].released = True - config.active_notes['mono'].env_time = 0.0 - config.active_notes['mono'].lfo_env_time = 0.0 # Reset LFO envelope time - elif 'mono' in config.active_notes: - # Some keys still pressed - switch to the last pressed key - last_key = config.mono_pressed_keys[-1] - midi_note = key_midi_map[last_key] + config.octave_offset + config.semitone_offset - freq = controller.midi_to_freq(midi_note) - - # Update oscillator target frequency for glide to new note - osc = config.active_notes['mono'] - osc.target_freq = freq - osc.key = last_key - osc.lfo_env_time = 0.0 # Reset LFO envelope time when switching notes in mono mode - - except AttributeError: - pass - - -def run_keyboard_listener(): - """Start the keyboard listener thread.""" - with keyboard.Listener(on_press=on_press, on_release=on_release) as listener: - listener.join() - - -def start_keyboard_input(): - """Start the keyboard input handling in a separate thread.""" - thread = threading.Thread(target=run_keyboard_listener, daemon=True) - thread.start() - return thread diff --git a/qwerty_synth/keyboard_midi.py b/qwerty_synth/keyboard_midi.py new file mode 100644 index 0000000..66307e7 --- /dev/null +++ b/qwerty_synth/keyboard_midi.py @@ -0,0 +1,239 @@ +"""Keyboard-to-MIDI translator for the QWERTY Synth.""" + +import logging +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Literal, Protocol + +from pynput import keyboard + +from qwerty_synth import config + +LOGGER = logging.getLogger(__name__) + +DEFAULT_VELOCITY = 100 +DEFAULT_CHANNEL = 0 + +MidiEventType = Literal['note_on', 'note_off', 'transpose', 'system_exit'] +MidiPayload = dict[str, int | float | str] + +# Default QWERTY-to-MIDI mapping (C-major layout). +DEFAULT_KEY_MIDI_MAP: dict[str, int] = { + 'a': 60, # C4 (middle C) + 'w': 61, # C#4 + 's': 62, # D4 + 'e': 63, # D#4 + 'd': 64, # E4 + 'f': 65, # F4 + 't': 66, # F#4 + 'g': 67, # G4 + 'y': 68, # G#4 + 'h': 69, # A4 (440Hz) + 'u': 70, # A#4 + 'j': 71, # B4 + 'k': 72, # C5 + 'o': 73, # C#5 + 'l': 74, # D5 + 'p': 75, # D#5 + ';': 76, # E5 + "'": 77, # F5 +} + +CONTROL_KEY_OCTAVE_DOWN = 'z' +CONTROL_KEY_OCTAVE_UP = 'x' + + +@dataclass(frozen=True, slots=True) +class MidiEvent: + """High-level MIDI-like event emitted by the keyboard translator.""" + + event_type: MidiEventType + note: int | None = None + velocity: int = DEFAULT_VELOCITY + channel: int = DEFAULT_CHANNEL + timestamp: float = field(default_factory=time.time) + payload: MidiPayload = field(default_factory=dict) + + +class MidiEventDispatcher(Protocol): + """Protocol describing the callback invoked for each emitted event.""" + + def __call__(self, event: MidiEvent) -> None: # pragma: no cover - protocol signature + ... + + +class KeyboardMidiTranslator: + """Translate QWERTY keyboard events into MIDI-style messages.""" + + def __init__( + self, + dispatcher: MidiEventDispatcher, + *, + velocity: int = DEFAULT_VELOCITY, + channel: int = DEFAULT_CHANNEL, + key_midi_map: dict[str, int] | None = None, + listener_cls: Callable[..., keyboard.Listener] = keyboard.Listener, + ) -> None: + if not callable(dispatcher): + raise TypeError('dispatcher must be callable') + if not 0 <= velocity <= 127: + raise ValueError('velocity must be within MIDI range 0-127') + if not 0 <= channel <= 15: + raise ValueError('channel must be within MIDI range 0-15') + + self._dispatcher = dispatcher + self._velocity = velocity + self._channel = channel + self._key_midi_map = dict(key_midi_map) if key_midi_map is not None else dict(DEFAULT_KEY_MIDI_MAP) + self._listener_cls = listener_cls + + self._listener: keyboard.Listener | None = None + self._lock = threading.Lock() + self._active_notes: dict[str, int] = {} + self._held_controls: set[str] = set() + + def start(self) -> keyboard.Listener: + """Start listening for keyboard input in a background thread.""" + if self._listener is not None: + return self._listener + + listener = self._listener_cls(on_press=self._on_press, on_release=self._on_release) + listener.start() + self._listener = listener + return listener + + def stop(self) -> None: + """Stop the keyboard listener and clear pending state.""" + if self._listener is not None: + self._listener.stop() + self._listener = None + + with self._lock: + self._active_notes.clear() + self._held_controls.clear() + + def _on_press(self, key: keyboard.Key | keyboard.KeyCode) -> None: + if key == keyboard.Key.esc: + self._dispatch(MidiEvent(event_type='system_exit', velocity=0)) + return + + if (char := self._safe_char(key)) is None: + return + + if char == CONTROL_KEY_OCTAVE_DOWN: + self._handle_control_press(char, semitone_delta=-12) + elif char == CONTROL_KEY_OCTAVE_UP: + self._handle_control_press(char, semitone_delta=12) + elif char in self._key_midi_map: + self._handle_note_press(char) + + def _on_release(self, key: keyboard.Key | keyboard.KeyCode) -> None: + if key == keyboard.Key.esc: + return + + if (char := self._safe_char(key)) is None: + return + + if char in {CONTROL_KEY_OCTAVE_DOWN, CONTROL_KEY_OCTAVE_UP}: + self._handle_control_release(char) + else: + self._handle_note_release(char) + + def _handle_note_press(self, key_char: str) -> None: + with self._lock: + if key_char in self._active_notes: + return + if (note := self._compute_midi_note(key_char)) is None: + return + self._active_notes[key_char] = note + + self._dispatch( + MidiEvent( + event_type='note_on', + note=note, + velocity=self._velocity, + channel=self._channel, + ) + ) + + def _handle_note_release(self, key_char: str) -> None: + with self._lock: + note = self._active_notes.pop(key_char, None) + + if note is not None: + self._dispatch( + MidiEvent( + event_type='note_off', + note=note, + velocity=0, + channel=self._channel, + ) + ) + + def _handle_control_press(self, control_char: str, *, semitone_delta: int) -> None: + with self._lock: + if control_char in self._held_controls: + return + self._held_controls.add(control_char) + + if not self._transpose_within_limits(semitone_delta): + return + + payload: MidiPayload = {'delta': semitone_delta, 'source': 'keyboard'} + self._dispatch( + MidiEvent( + event_type='transpose', + velocity=0, + channel=self._channel, + payload=payload, + ) + ) + + def _handle_control_release(self, control_char: str) -> None: + with self._lock: + self._held_controls.discard(control_char) + + def _compute_midi_note(self, key_char: str) -> int | None: + base_note = self._key_midi_map.get(key_char) + if base_note is None: + return None + + with config.notes_lock: + octave_offset = config.octave_offset + semitone_offset = config.semitone_offset + + midi_note = base_note + octave_offset + semitone_offset + return max(0, min(127, midi_note)) + + def _transpose_within_limits(self, semitone_delta: int) -> bool: + with config.notes_lock: + new_offset = config.octave_offset + semitone_delta + minimum = 12 * config.octave_min + maximum = 12 * config.octave_max + + return minimum <= new_offset <= maximum + + def _dispatch(self, event: MidiEvent) -> None: + try: + self._dispatcher(event) + except (TypeError, ValueError, AttributeError, KeyError) as exc: # pragma: no cover + LOGGER.exception('Exception while dispatching MIDI event %s: %s', event, exc) + + @staticmethod + def _safe_char(key: keyboard.Key | keyboard.KeyCode) -> str | None: + try: + char = key.char + except AttributeError: + return None + + return char.lower() if char else None + + +__all__ = [ + 'DEFAULT_KEY_MIDI_MAP', + 'KeyboardMidiTranslator', + 'MidiEvent', + 'MidiEventDispatcher', +] diff --git a/tests/test_input.py b/tests/test_input.py index 3be8aef..8da2fe3 100644 --- a/tests/test_input.py +++ b/tests/test_input.py @@ -1,508 +1,143 @@ -"""Comprehensive tests for the input module.""" - -from unittest.mock import Mock, patch -from pynput import keyboard - -from qwerty_synth import input as input_module -from qwerty_synth import config - - -class TestKeyMidiMapping: - """Test cases for key to MIDI note mapping.""" - - def test_key_midi_map_completeness(self): - """Test that all expected keys are mapped to MIDI notes.""" - expected_keys = ['a', 'w', 's', 'e', 'd', 'f', 't', 'g', 'y', 'h', 'u', 'j', 'k', 'o', 'l', 'p', ';', "'"] - - for key in expected_keys: - assert key in input_module.key_midi_map - assert isinstance(input_module.key_midi_map[key], int) - assert 60 <= input_module.key_midi_map[key] <= 77 # C4 to F5 - - def test_key_midi_map_values(self): - """Test specific MIDI note mappings.""" - assert input_module.key_midi_map['a'] == 60 # C4 - assert input_module.key_midi_map['h'] == 69 # A4 (440Hz) - assert input_module.key_midi_map['k'] == 72 # C5 - - def test_chromatic_sequence(self): - """Test that mapped keys form a chromatic sequence.""" - # Test a subset of the chromatic sequence - assert input_module.key_midi_map['a'] == 60 # C4 - assert input_module.key_midi_map['w'] == 61 # C#4 - assert input_module.key_midi_map['s'] == 62 # D4 - assert input_module.key_midi_map['e'] == 63 # D#4 - - -class TestOnPress: - """Test cases for key press handling.""" - - def setup_method(self): - """Reset state before each test.""" - config.active_notes = {} - config.mono_pressed_keys = [] - config.mono_mode = False - config.octave_offset = 0 - config.octave_min = -2 - config.octave_max = 3 - config.waveform_type = 'sine' - input_module.gui_instance = None - - @patch('qwerty_synth.controller.midi_to_freq') - @patch('qwerty_synth.synth.Oscillator') - def test_on_press_polyphonic_mode(self, mock_oscillator, mock_midi_to_freq): - """Test key press in polyphonic mode.""" - config.mono_mode = False - mock_midi_to_freq.return_value = 440.0 - mock_osc = Mock() - mock_osc.key = 'a' - mock_oscillator.return_value = mock_osc - - # Create mock key - mock_key = Mock() - mock_key.char = 'a' - - input_module.on_press(mock_key) - - # Should create oscillator and add to active notes - mock_midi_to_freq.assert_called_once_with(60) # a = 60 + 0 offset - mock_oscillator.assert_called_once_with(440.0, 'sine') - assert 'a' in config.active_notes - assert config.active_notes['a'] == mock_osc - - @patch('qwerty_synth.controller.midi_to_freq') - @patch('qwerty_synth.synth.Oscillator') - def test_on_press_mono_mode_new_note(self, mock_oscillator, mock_midi_to_freq): - """Test key press in mono mode with no existing note.""" - config.mono_mode = True - mock_midi_to_freq.return_value = 440.0 - mock_osc = Mock() - mock_osc.key = 'mono' - mock_osc.released = False - mock_oscillator.return_value = mock_osc - - mock_key = Mock() - mock_key.char = 'a' - - input_module.on_press(mock_key) - - # Should create mono oscillator - mock_oscillator.assert_called_once_with(440.0, 'sine') - assert 'mono' in config.active_notes - assert 'a' in config.mono_pressed_keys - - @patch('qwerty_synth.controller.midi_to_freq') - def test_on_press_mono_mode_update_frequency(self, mock_midi_to_freq): - """Test key press in mono mode with existing note.""" - config.mono_mode = True - mock_midi_to_freq.return_value = 550.0 - - # Create existing mono oscillator - existing_osc = Mock() - existing_osc.released = False - config.active_notes['mono'] = existing_osc - - mock_key = Mock() - mock_key.char = 's' # D4 - - input_module.on_press(mock_key) - - # Should update target frequency - assert existing_osc.target_freq == 550.0 - assert 's' in config.mono_pressed_keys - - def test_on_press_octave_down(self): - """Test octave down key press.""" - config.octave_offset = 12 # Start at +1 octave - - mock_key = Mock() - mock_key.char = 'z' - - with patch('builtins.print') as mock_print: - input_module.on_press(mock_key) - - assert config.octave_offset == 0 # Should decrease by 12 - mock_print.assert_called_once_with('Octave down: +0') - - def test_on_press_octave_up(self): - """Test octave up key press.""" - config.octave_offset = 0 # Start at middle octave - - mock_key = Mock() - mock_key.char = 'x' - - with patch('builtins.print') as mock_print: - input_module.on_press(mock_key) - - assert config.octave_offset == 12 # Should increase by 12 - mock_print.assert_called_once_with('Octave up: +1') - - def test_on_press_octave_limits(self): - """Test octave change limits.""" - # Test lower limit - config.octave_offset = 12 * config.octave_min # At minimum - mock_key = Mock() - mock_key.char = 'z' - - input_module.on_press(mock_key) - assert config.octave_offset == 12 * config.octave_min # Should not change - - # Test upper limit - config.octave_offset = 12 * config.octave_max # At maximum - mock_key.char = 'x' - - input_module.on_press(mock_key) - assert config.octave_offset == 12 * config.octave_max # Should not change - - def test_on_press_with_gui_instance(self): - """Test key press with GUI instance present.""" - # Create mock GUI instance - mock_gui = Mock() - mock_gui.running = True - mock_gui.octave_label = Mock() - mock_gui.octave_dial = Mock() - input_module.gui_instance = mock_gui - - config.octave_offset = 0 - mock_key = Mock() - mock_key.char = 'x' - - input_module.on_press(mock_key) +"""Integration tests covering keyboard events routed through the controller.""" - # Should update config but not directly update GUI elements - assert config.octave_offset == 12 - # GUI updates are now handled by the GUI's update_plots method - - def test_on_press_with_octave_offset(self): - """Test key press with octave offset applied.""" - config.octave_offset = 12 # +1 octave +from types import SimpleNamespace +from unittest.mock import Mock, patch, call - mock_key = Mock() - mock_key.char = 'a' # C4 normally +import pytest - with patch('qwerty_synth.controller.midi_to_freq') as mock_midi_to_freq: - with patch('qwerty_synth.synth.Oscillator'): - mock_midi_to_freq.return_value = 523.25 # C5 +from qwerty_synth import config, controller +from qwerty_synth.keyboard_midi import MidiEvent - input_module.on_press(mock_key) - # Should call with offset MIDI note - mock_midi_to_freq.assert_called_once_with(72) # 60 + 12 +@pytest.fixture(autouse=True) +def reset_controller_state(): + """Ensure controller state is reset before and after each test.""" + controller.reset_keyboard_state() + yield + controller.reset_keyboard_state() - @patch('sounddevice.stop') - def test_on_press_escape_key(self, mock_sd_stop): - """Test escape key press without GUI.""" - mock_key = keyboard.Key.esc - with patch('builtins.print') as mock_print: - result = input_module.on_press(mock_key) +def make_event(event_type: str, note: int | None = None, velocity: int = 100, payload=None) -> MidiEvent: + """Helper to build MIDI events with sensible defaults.""" + return MidiEvent(event_type=event_type, note=note, velocity=velocity, payload=payload or {}) - mock_print.assert_called_once_with('Exiting...') - mock_sd_stop.assert_called_once() - assert result is False # Should return False to stop listener - @patch('sounddevice.stop') - @patch('PyQt5.QtCore.QTimer') - def test_on_press_escape_with_gui(self, mock_qtimer, mock_sd_stop): - """Test escape key press with GUI instance.""" - mock_gui = Mock() - input_module.gui_instance = mock_gui - mock_key = keyboard.Key.esc +@patch('qwerty_synth.controller.Oscillator') +@patch('qwerty_synth.controller.midi_to_freq', return_value=440.0) +def test_handle_midi_message_polyphonic_spawns_and_releases(mock_midi_to_freq, mock_oscillator): + """Polyphonic mode should create an oscillator and release it on note off.""" + osc = Mock() + osc.released = False + osc.env_time = None + osc.lfo_env_time = None + mock_oscillator.return_value = osc - with patch('builtins.print'): - result = input_module.on_press(mock_key) - - # Should use QTimer.singleShot to schedule close on main thread - mock_qtimer.singleShot.assert_called_once_with(0, mock_gui.close) - assert result is False - - def test_on_press_unknown_key(self): - """Test pressing an unmapped key.""" - mock_key = Mock() - mock_key.char = 'q' # Not in key_midi_map - - # Should not raise exception and not modify state - input_module.on_press(mock_key) - assert len(config.active_notes) == 0 - assert len(config.mono_pressed_keys) == 0 - - def test_on_press_special_key_without_char(self): - """Test pressing a special key without char attribute.""" - mock_key = Mock() - del mock_key.char # Remove char attribute - - # Should handle AttributeError gracefully - input_module.on_press(mock_key) - assert len(config.active_notes) == 0 - - -class TestOnRelease: - """Test cases for key release handling.""" - - def setup_method(self): - """Reset state before each test.""" - config.active_notes = {} - config.mono_pressed_keys = [] - config.mono_mode = False - - def test_on_release_polyphonic_mode(self): - """Test key release in polyphonic mode.""" - config.mono_mode = False - - # Create mock oscillator - mock_osc = Mock() - mock_osc.released = False - config.active_notes['a'] = mock_osc + controller.handle_midi_message(make_event('note_on', note=60, velocity=100)) - mock_key = Mock() - mock_key.char = 'a' - - input_module.on_release(mock_key) - - # Should release the oscillator - assert mock_osc.released is True - assert mock_osc.env_time == 0.0 - assert mock_osc.lfo_env_time == 0.0 + mock_midi_to_freq.assert_called_once_with(60) + assert 'keyboard_60' in config.active_notes + assert config.active_notes['keyboard_60'] is osc + assert pytest.approx(osc.velocity, rel=1e-4) == 100 / 127.0 - def test_on_release_mono_mode_last_key(self): - """Test key release in mono mode when it's the last key.""" - config.mono_mode = True - config.mono_pressed_keys = ['a'] - - # Create mock mono oscillator - mock_osc = Mock() - mock_osc.released = False - config.active_notes['mono'] = mock_osc - - mock_key = Mock() - mock_key.char = 'a' - - input_module.on_release(mock_key) - - # Should release mono oscillator and remove key from list - assert mock_osc.released is True - assert mock_osc.env_time == 0.0 - assert mock_osc.lfo_env_time == 0.0 - assert 'a' not in config.mono_pressed_keys - - @patch('qwerty_synth.controller.midi_to_freq') - def test_on_release_mono_mode_switch_note(self, mock_midi_to_freq): - """Test key release in mono mode with other keys still pressed.""" - config.mono_mode = True - config.mono_pressed_keys = ['a', 's'] # Two keys pressed - mock_midi_to_freq.return_value = 293.66 # D4 - - # Create mock mono oscillator - mock_osc = Mock() - mock_osc.released = False - config.active_notes['mono'] = mock_osc - - mock_key = Mock() - mock_key.char = 'a' # Release first key - - input_module.on_release(mock_key) - - # Should switch to the last pressed key (s) - assert mock_osc.target_freq == 293.66 - assert mock_osc.key == 's' - assert mock_osc.lfo_env_time == 0.0 - assert 'a' not in config.mono_pressed_keys - assert 's' in config.mono_pressed_keys - - def test_on_release_key_not_in_active_notes(self): - """Test releasing a key that's not in active notes.""" - mock_key = Mock() - mock_key.char = 'a' - - # Should handle gracefully - input_module.on_release(mock_key) - assert len(config.active_notes) == 0 - - def test_on_release_key_not_in_mono_pressed_keys(self): - """Test releasing a key that's not in mono_pressed_keys.""" - config.mono_mode = True - config.mono_pressed_keys = ['s'] # Different key - - mock_key = Mock() - mock_key.char = 'a' - - input_module.on_release(mock_key) - assert config.mono_pressed_keys == ['s'] # Should remain unchanged - - def test_on_release_special_key_without_char(self): - """Test releasing a special key without char attribute.""" - mock_key = Mock() - del mock_key.char # Remove char attribute + controller.handle_midi_message(make_event('note_off', note=60, velocity=0)) - # Should handle AttributeError gracefully - input_module.on_release(mock_key) - assert len(config.active_notes) == 0 - - def test_on_release_mono_mode_no_mono_oscillator(self): - """Test mono mode release when no mono oscillator exists.""" - config.mono_mode = True - config.mono_pressed_keys = ['a'] - - mock_key = Mock() - mock_key.char = 'a' - - # Should handle gracefully - input_module.on_release(mock_key) - assert 'a' not in config.mono_pressed_keys - - -class TestKeyboardListener: - """Test cases for keyboard listener functionality.""" - - @patch('qwerty_synth.input.keyboard.Listener') - def test_run_keyboard_listener(self, mock_listener_class): - """Test running the keyboard listener.""" - mock_listener = Mock() - mock_listener_class.return_value.__enter__ = Mock(return_value=mock_listener) - mock_listener_class.return_value.__exit__ = Mock(return_value=None) - - input_module.run_keyboard_listener() - - # Should create listener with correct callbacks - mock_listener_class.assert_called_once_with( - on_press=input_module.on_press, - on_release=input_module.on_release - ) - mock_listener.join.assert_called_once() - - @patch('qwerty_synth.input.run_keyboard_listener') - @patch('threading.Thread') - def test_start_keyboard_input(self, mock_thread, mock_run_listener): - """Test starting keyboard input in a thread.""" - mock_thread_instance = Mock() - mock_thread.return_value = mock_thread_instance - - result = input_module.start_keyboard_input() - - # Should create and start daemon thread - mock_thread.assert_called_once_with( - target=input_module.run_keyboard_listener, - daemon=True - ) - mock_thread_instance.start.assert_called_once() - assert result == mock_thread_instance - - -class TestGuiIntegration: - """Test cases for GUI integration.""" - - def setup_method(self): - """Reset state before each test.""" - input_module.gui_instance = None - config.octave_offset = 0 - - def test_gui_instance_assignment(self): - """Test that gui_instance can be assigned.""" - mock_gui = Mock() - input_module.gui_instance = mock_gui - assert input_module.gui_instance == mock_gui - - def test_octave_change_without_gui(self): - """Test octave change when no GUI instance is set.""" - input_module.gui_instance = None - config.octave_offset = 0 - - mock_key = Mock() - mock_key.char = 'x' - - # Should not raise exception - with patch('builtins.print'): - input_module.on_press(mock_key) - - assert config.octave_offset == 12 - - def test_octave_change_with_non_running_gui(self): - """Test octave change when GUI instance is not running.""" - mock_gui = Mock() - mock_gui.running = False - input_module.gui_instance = mock_gui - - config.octave_offset = 0 - mock_key = Mock() - mock_key.char = 'x' - - with patch('builtins.print'): - input_module.on_press(mock_key) - - # Should update config regardless of GUI state - assert config.octave_offset == 12 - # GUI updates are handled by the GUI's own update cycle, not by input module - - -class TestEdgeCases: - """Test cases for edge cases and error conditions.""" - - def setup_method(self): - """Reset state before each test.""" - config.active_notes = {} - config.mono_pressed_keys = [] - config.mono_mode = False - config.octave_offset = 0 - - def test_case_insensitive_key_handling(self): - """Test that uppercase keys are handled correctly.""" - mock_key = Mock() - mock_key.char = 'A' # Uppercase - - with patch('qwerty_synth.controller.midi_to_freq') as mock_midi_to_freq: - with patch('qwerty_synth.synth.Oscillator'): - mock_midi_to_freq.return_value = 440.0 - - input_module.on_press(mock_key) - - # Should convert to lowercase and process - assert 'a' in config.active_notes - - def test_concurrent_key_presses(self): - """Test handling multiple concurrent key presses.""" - config.mono_mode = False - - with patch('qwerty_synth.controller.midi_to_freq') as mock_midi_to_freq: - with patch('qwerty_synth.synth.Oscillator') as mock_oscillator: - mock_midi_to_freq.return_value = 440.0 - mock_oscillator.side_effect = [Mock(key='a'), Mock(key='s'), Mock(key='d')] - - # Press multiple keys - for char in ['a', 's', 'd']: - mock_key = Mock() - mock_key.char = char - input_module.on_press(mock_key) - - assert len(config.active_notes) == 3 - assert all(key in config.active_notes for key in ['a', 's', 'd']) + assert osc.released is True + assert osc.env_time == 0.0 + assert osc.lfo_env_time == 0.0 - def test_mono_mode_key_order_tracking(self): - """Test that mono mode correctly tracks key press order.""" - config.mono_mode = True - - with patch('qwerty_synth.controller.midi_to_freq'): - with patch('qwerty_synth.synth.Oscillator'): - # Press keys in sequence - for char in ['a', 's', 'd']: - mock_key = Mock() - mock_key.char = char - input_module.on_press(mock_key) - # Should track all pressed keys in order - assert config.mono_pressed_keys == ['a', 's', 'd'] - - def test_extreme_octave_values(self): - """Test handling of extreme octave offset values.""" - # Test with values at the boundaries - config.octave_offset = 12 * config.octave_min - mock_key = Mock() - mock_key.char = 'z' - - input_module.on_press(mock_key) - assert config.octave_offset == 12 * config.octave_min # Should not go below minimum +@patch('qwerty_synth.controller.Oscillator') +@patch('qwerty_synth.controller.midi_to_freq') +def test_handle_midi_message_mono_maintains_last_pressed(mock_midi_to_freq, mock_oscillator): + """Mono mode should glide to the last pressed note and release when all keys lift.""" + config.mono_mode = True + mock_midi_to_freq.side_effect = [440.0, 554.37, 440.0] - config.octave_offset = 12 * config.octave_max - mock_key.char = 'x' + osc = Mock() + osc.released = False + osc.env_time = 0.0 + osc.lfo_env_time = 0.0 + osc.velocity = 0.0 + mock_oscillator.return_value = osc - input_module.on_press(mock_key) - assert config.octave_offset == 12 * config.octave_max # Should not go above maximum + controller.handle_midi_message(make_event('note_on', note=60, velocity=100)) + assert config.mono_pressed_keys == [60] + assert config.active_notes['mono'] is osc + + controller.handle_midi_message(make_event('note_on', note=64, velocity=90)) + assert config.mono_pressed_keys == [60, 64] + assert osc.target_freq == 554.37 + assert osc.lfo_env_time == 0.0 + + controller.handle_midi_message(make_event('note_off', note=64)) + assert config.mono_pressed_keys == [60] + assert osc.target_freq == 440.0 + + controller.handle_midi_message(make_event('note_off', note=60)) + assert config.mono_pressed_keys == [] + assert osc.released is True + + +@patch('qwerty_synth.controller.Oscillator') +@patch('qwerty_synth.controller._get_arpeggiator_module') +def test_handle_midi_message_arpeggiator_gate(mock_get_arpeggiator_module, mock_oscillator): + """When sustain base is disabled, arpeggiator events should not spawn oscillators.""" + config.arpeggiator_enabled = True + config.arpeggiator_sustain_base = False + + arp_instance = Mock() + mock_get_arpeggiator_module.return_value = SimpleNamespace(arpeggiator_instance=arp_instance) + + controller.handle_midi_message(make_event('note_on', note=60, velocity=80)) + + arp_instance.add_note.assert_called_once_with(60) + mock_oscillator.assert_not_called() + assert config.active_notes == {} + + controller.handle_midi_message(make_event('note_off', note=60)) + arp_instance.remove_note.assert_called_once_with(60) + + +def test_apply_transpose_delta_prints_and_bounds(): + """Transpose helper should respect configuration bounds and provide user feedback.""" + config.octave_min = -1 + config.octave_max = 1 + + with patch('builtins.print') as mock_print: + assert controller.apply_transpose_delta(12) is True + assert controller.apply_transpose_delta(-12) is True + assert controller.apply_transpose_delta(-12) is True + assert mock_print.call_args_list == [ + call('Octave up: +1'), + call('Octave down: +0'), + call('Octave down: -1'), + ] + + with patch('builtins.print') as mock_print: + assert controller.apply_transpose_delta(-12) is False + mock_print.assert_not_called() + + +@patch('qwerty_synth.controller.Oscillator') +@patch('qwerty_synth.controller.midi_to_freq', return_value=440.0) +def test_reset_keyboard_state_clears_tracking(mock_midi_to_freq, mock_oscillator): + """Resetting the controller should drop pressed-note bookkeeping.""" + config.mono_mode = True + osc = Mock() + osc.released = False + mock_oscillator.return_value = osc + + controller.handle_midi_message(make_event('note_on', note=60)) + assert config.mono_pressed_keys == [60] + + controller.reset_keyboard_state() + assert config.mono_pressed_keys == [] + controller.handle_midi_message(make_event('note_on', note=60)) + mock_midi_to_freq.assert_called_with(60) + + +def test_handle_midi_message_unknown_event_noop(): + """Unknown event types should not raise errors.""" + controller.handle_midi_message(make_event('pitch_bend', note=None)) + # Simply ensure no exceptions and state remains untouched + assert config.active_notes == {} diff --git a/tests/test_keyboard_integration.py b/tests/test_keyboard_integration.py new file mode 100644 index 0000000..441fb42 --- /dev/null +++ b/tests/test_keyboard_integration.py @@ -0,0 +1,78 @@ +"""Integration tests covering keyboard translator to controller flows.""" + +from unittest.mock import Mock + +import pytest +from pynput.keyboard import Key, KeyCode + +from qwerty_synth import config, controller +from qwerty_synth.keyboard_midi import KeyboardMidiTranslator + + +@pytest.fixture(autouse=True) +def reset_keyboard_state(): + controller.reset_keyboard_state() + yield + controller.reset_keyboard_state() + + +class DummyListener: + """Minimal listener stub to avoid threading during tests.""" + + def __init__(self, *, on_press, on_release): + self.on_press = on_press + self.on_release = on_release + self.started = False + + def start(self): + self.started = True + return self + + def stop(self): + self.started = False + + +def make_translator(dispatcher): + return KeyboardMidiTranslator(dispatcher=dispatcher, listener_cls=DummyListener) + + +def test_translator_note_flow_polyphonic(): + translator = make_translator(controller.handle_midi_message) + translator.start() + + translator._on_press(KeyCode.from_char('a')) + assert 'keyboard_60' in config.active_notes + + translator._on_release(KeyCode.from_char('a')) + assert config.active_notes['keyboard_60'].released is True + + translator.stop() + + +def test_translator_respects_transpose_controls(): + translator = make_translator(controller.handle_midi_message) + translator.start() + + translator._on_press(KeyCode.from_char('x')) + translator._on_release(KeyCode.from_char('x')) + assert controller.get_octave_offset() == 12 + + translator._on_press(KeyCode.from_char('z')) + translator._on_release(KeyCode.from_char('z')) + assert controller.get_octave_offset() == 0 + + translator.stop() + + +def test_system_exit_event_dispatch(): + dispatcher = Mock() + translator = make_translator(dispatcher) + translator.start() + + translator._on_press(Key.esc) + + event = dispatcher.call_args[0][0] + assert event.event_type == 'system_exit' + assert event.velocity == 0 + + translator.stop() diff --git a/tests/test_keyboard_midi.py b/tests/test_keyboard_midi.py new file mode 100644 index 0000000..48222d5 --- /dev/null +++ b/tests/test_keyboard_midi.py @@ -0,0 +1,158 @@ +"""Tests for the keyboard-to-MIDI translator module.""" + +import pytest +from pynput.keyboard import Key, KeyCode + +from qwerty_synth import config +from qwerty_synth.keyboard_midi import ( + DEFAULT_KEY_MIDI_MAP, + KeyboardMidiTranslator, + MidiEvent, +) + + +@pytest.fixture(autouse=True) +def reset_config_state(): + """Reset shared config state before each test.""" + config.octave_offset = 0 + config.semitone_offset = 0 + config.octave_min = -2 + config.octave_max = 3 + yield + + +def test_note_on_off_dispatch(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append, velocity=96, channel=2) + + translator._on_press(KeyCode.from_char('a')) + translator._on_release(KeyCode.from_char('a')) + + assert [event.event_type for event in events] == ['note_on', 'note_off'] + assert events[0].note == DEFAULT_KEY_MIDI_MAP['a'] + assert events[1].note == DEFAULT_KEY_MIDI_MAP['a'] + assert events[0].velocity == 96 + assert events[0].channel == 2 + assert events[1].velocity == 0 + + +def test_repeated_keydown_does_not_duplicate_events(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append) + + key = KeyCode.from_char('s') + translator._on_press(key) + translator._on_press(key) + translator._on_release(key) + + assert [event.event_type for event in events] == ['note_on', 'note_off'] + + +def test_note_off_ignored_if_not_pressed(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append) + + translator._on_release(KeyCode.from_char('d')) + + assert events == [] + + +def test_octave_offset_applied_to_note(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append) + config.octave_offset = 12 # +1 octave + + translator._on_press(KeyCode.from_char('a')) + translator._on_release(KeyCode.from_char('a')) + + assert events[0].note == DEFAULT_KEY_MIDI_MAP['a'] + 12 + assert events[1].note == DEFAULT_KEY_MIDI_MAP['a'] + 12 + + +def test_control_key_transpose_dispatches_event_within_limits(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append) + + translator._on_press(KeyCode.from_char('x')) + + assert len(events) == 1 + event = events[0] + assert event.event_type == 'transpose' + assert event.payload['delta'] == 12 + assert event.payload['source'] == 'keyboard' + + +def test_control_key_respects_octave_limits(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append) + config.octave_offset = 12 * config.octave_max + + translator._on_press(KeyCode.from_char('x')) + + assert events == [] + + +def test_control_key_release_clears_hold_state(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append) + + key = KeyCode.from_char('z') + translator._on_press(key) + translator._on_release(key) + translator._on_press(key) + + assert len(events) == 2 # Two valid transpose events from two distinct presses + + +def test_escape_key_emits_system_exit(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append) + + translator._on_press(Key.esc) + + assert len(events) == 1 + assert events[0].event_type == 'system_exit' + + +def test_note_values_clamped_to_midi_range(): + events: list[MidiEvent] = [] + translator = KeyboardMidiTranslator(dispatcher=events.append) + config.octave_offset = 100 # intentionally large to exceed MIDI range + + translator._on_press(KeyCode.from_char('k')) # Highest mapped note + translator._on_release(KeyCode.from_char('k')) + + assert events[0].note == 127 + assert events[1].note == 127 + + +def test_custom_listener_is_respected(): + events: list[MidiEvent] = [] + + class FakeListener: + def __init__(self, *, on_press, on_release): + self.on_press = on_press + self.on_release = on_release + self.started = False + + def start(self): + self.started = True + return self + + def stop(self): + self.started = False + + translator = KeyboardMidiTranslator( + dispatcher=events.append, + listener_cls=FakeListener, + ) + + listener = translator.start() + assert isinstance(listener, FakeListener) + assert listener.started is True + + # Ensure start is idempotent + assert translator.start() is listener + + translator.stop() + assert listener.started is False