Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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.
and mention the result of running the test suite.
71 changes: 71 additions & 0 deletions docs/keyboard_to_midi_plan.md
Original file line number Diff line number Diff line change
@@ -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.
210 changes: 210 additions & 0 deletions qwerty_synth/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading