-
Notifications
You must be signed in to change notification settings - Fork 2
How it Works
- Playback Pipeline
- Key Offset Flow
- Default Key Offset vs Key Offset
- Auto-key Detection
- Transpose Flow (Ignore / Up / Down / Smart)
- Keypress Backends (Windows APIs / Libraries)
- Advanced File Handling Scenarios
- Playback Safety and Focus
At a high level:
- Song is loaded and MIDI tracks are parsed.
- Track filters and song settings are applied.
- For every note event, key offset and transpose are applied.
- Converted note is mapped to a key for the selected instrument/layout.
- Playback emits keyboard events at runtime.
- UI transport and queue state are updated continuously.
Under the hood, note conversion happens in this order:
private int ApplyNoteSettings(string instrumentId, int noteId)
{
noteId -= SongSettings.GetEffectiveKeyOffset(Queue.OpenedFile?.Song);
return Settings.TransposeNotes && SongSettings.Transpose is not null
? KeyboardPlayer.TransposeNote(instrumentId, ref noteId, SongSettings.Transpose.Value.Key)
: noteId;
}When a MIDI note comes in, the player first applies the effective key offset.
public static int GetEffectiveKeyOffset(int keyOffset, int? defaultKeyOffset)
{
if (defaultKeyOffset is null)
return Math.Clamp(keyOffset, MinKeyOffset, MaxKeyOffset);
return Math.Clamp(defaultKeyOffset.Value + keyOffset, MinKeyOffset, MaxKeyOffset);
}After that, the app tries to map the final note to a real key for the selected game + instrument.
This is the most important part to avoid confusion:
-
Default Key Offset (
Song.DefaultKey) This is the detected or assigned song key center (root reference). -
Key Offset (
Song.Key) This is your per-song adjustment value. -
Effective Key Offset This is what playback actually uses:
Effective = DefaultKey + KeyOffset(whenDefaultKeyexists).
If DefaultKey is null (legacy songs), KeyOffset behaves as an absolute value.
Example:
DefaultKey = +2 (D3)KeyOffset = -1EffectiveKeyOffset = +1 (C#3)
For new songs, auto-detect usually sets DefaultKey, and KeyOffset starts at 0.
Quick note:
- In normal playback, the app uses
EffectiveKeyOffset. - In listen mode preview, it intentionally applies only the relative
KeyOffsetwhenDefaultKeyexists.
When Auto Detect Default Key is enabled, newly added songs can automatically get a detected default key center.
Flow summary:
- Try MIDI key signature first.
- If missing, fall back to pitch-class profile analysis.
- Normalize detected tonic around C3-style signed offsets.
- Store result in
Song.DefaultKeyand resetSong.Keyto0(relative mode).
Core detection flow:
private static bool TryDetectSongKeyOffset(Melanchall.DryWetMidi.Core.MidiFile midi, out int keyOffset)
{
if (TryDetectFromKeySignature(midi, out keyOffset))
return true;
return TryDetectFromPitchClassProfile(midi, out keyOffset);
}Apply to newly loaded songs:
if (!ShouldAutoDetectSongKey(song))
return;
if (!TryDetectSongKeyOffset(loadedFile.Midi, out var detectedKey))
return;
song.DefaultKey = detectedKey;
song.Key = 0;Why this matters: DefaultKey becomes the song's center, and KeyOffset stays as your relative adjustment on top.
No transposition is applied. Out-of-range handling depends on playback settings.
If a note is out of instrument range, it is first octave-folded toward the playable range. If it is still not a playable note, it moves semitone-by-semitone in the selected direction until it matches.
while (true)
{
if (noteSet.Contains(noteId))
return noteId;
if (noteId < minNote)
noteId += 12;
else if (noteId > maxNote)
noteId -= 12;
else
noteId = direction switch
{
Transpose.Up => ++noteId,
Transpose.Down => --noteId,
_ => noteId
};
}Smart mode tries to keep the musical feel better by choosing a note that is both playable and musically close.
Yes, Smart mode internally evaluates multiple scale modes:
private static readonly int[][] SmartModes =
[
[0, 2, 4, 5, 7, 9, 11], // ionian (major)
[0, 2, 3, 5, 7, 9, 10], // dorian
[0, 1, 3, 5, 7, 8, 10], // phrygian
[0, 2, 4, 6, 7, 9, 11], // lydian
[0, 2, 4, 5, 7, 9, 10], // mixolydian
[0, 2, 3, 5, 7, 8, 10], // aeolian (minor)
[0, 1, 3, 5, 6, 8, 10] // locrian
];Important
These are not user-selectable modes in the UI. Smart mode automatically tests tonic + mode combinations and picks the best fit from note context.
Selection step (simplified):
for (var tonic = 0; tonic < 12; tonic++)
{
for (var modeIndex = 0; modeIndex < SmartModes.Length; modeIndex++)
{
var score = 0.0;
foreach (var interval in SmartModes[modeIndex])
score += histogram[(tonic + interval) % 12];
if (score > bestScore)
{
bestScore = score;
bestTonic = tonic;
bestMode = modeIndex;
}
}
}High-level flow from KeyboardPlayer.SmartTransposeNote(...):
private static int SmartTransposeNote(IList<int> notes, HashSet<int> noteSet, int originalNote)
{
if (noteSet.Contains(originalNote))
return originalNote;
var targetNote = FoldNoteIntoRangeByOctaves(originalNote, minNote, maxNote);
var (tonic, modeIndex) = DetectBestScale(notes, targetNote);
var scalePitchClasses = BuildScalePitchClassSet(tonic, modeIndex);
var scaleCandidates = notes.Where(note => scalePitchClasses.Contains(Mod12(note))).ToList();
var candidates = scaleCandidates.Count > 0 ? scaleCandidates : notes.ToList();
var best = candidates[0];
var bestScore = double.PositiveInfinity;
foreach (var candidate in candidates)
{
var targetDistance = Math.Abs(candidate - targetNote);
var octaveDistance = Math.Abs((candidate / 12) - (targetNote / 12));
var score = targetDistance + (octaveDistance * 0.15);
if (score < bestScore)
{
bestScore = score;
best = candidate;
}
}
return best;
}Why this feels more natural than simple up/down transposition:
- It first octave-folds extreme notes into the playable range.
- It estimates a best-fit scale/mode from current playable notes.
- It prefers notes inside that scale before falling back to all notes.
- It scores by pitch distance, with a small octave penalty to preserve contour.
This helps avoid flattening dense passages into the very top or bottom playable notes.
The app supports three keypress backends from Settings:
-
Input Simulator
Uses the
InputSimulatorlibrary for global keyboard injection. -
Direct Input (SendInput)
Uses Win32
SendInputAPI via P/Invoke. -
Window Message (PostMessage)
Sends
WM_KEYDOWN/WM_KEYUPdirectly to the active game window.
Practical note:
-
Direct Input (SendInput)is generally the default/recommended mode for games. -
Window Message (PostMessage)is useful for games that block injected global input. -
Input Simulatoris the most general desktop-compatible path.
This section documents the current advanced behavior for auto-scan, exclusions, duplicates, missing files, and realtime dialog refresh.
| Scenario | Trigger / Action | Result |
|---|---|---|
| New MIDI added in watched folder | Watcher gets a Created event (or user runs a manual scan) |
App imports the file if it is not excluded and not already in the library by hash/path. |
| MIDI deleted from watched folder | Watcher gets a Deleted event, then debounced scan runs |
Song moves to Missing, is removed from active lists/queue, and duplicate or bad-file entries for that path are removed. |
| MIDI renamed to non-MIDI extension | Watcher Renamed handler checks old and new path, then scans |
Works like a delete for the old MIDI path; Missing/error lists are updated. |
| Non-MIDI renamed to MIDI | Watcher Renamed event triggers scan |
File becomes a new MIDI import option, if it passes exclusion and duplicate/hash checks. |
| Configured MIDI folder renamed away (root folder name changed) | Parent-folder watcher sees rename/delete of the watched folder path, then debounced scan runs | Even if the configured path is temporarily missing, app still updates state, so songs from that folder move to Missing and are removed from active lists/queue. |
| Configured MIDI folder renamed back to original path | Parent-folder watcher sees folder create/rename-back and sets up child watcher again | Auto-scan restores songs from disk (including same-hash Missing restore) with no manual refresh needed. |
| Folder delete/rename under watched root | Watcher path filter allows directory events even if path no longer exists | Scan marks all songs in that folder subtree as Missing. |
| Burst of file changes | Many watcher events happen in a short time | Debounce cancels older scans and runs only the newest scan. |
| Manual song deletion from Song List (file still exists in MIDI folder) | User deletes song from library UI | File path is added to auto-import exclusion list (for files inside configured MIDI folder), song is removed from DB/lists, file stays on disk. |
| Excluded file appears in "Removed but still in MIDI folder" | File exists on disk and is excluded | Appears in File Issues under "Removed but still in MIDI folder". |
| Restore excluded file | User clicks Restore in File Issues | Exclusion is removed, file is imported back (if it still exists), and related Missing/duplicate entries are cleaned up. |
| Delete excluded file from disk | User clicks Delete in File Issues and confirms | File is deleted from disk (if possible), exclusion is removed, and related Missing/duplicate/bad entries are refreshed. |
| Missing song reappears at new path with same hash | Scan/import finds a hash match in Missing list | App restores the same song record with new path/hash, updates DB row, and removes it from Missing list. |
| Duplicate discovered by file hash | New file hash matches an already imported track | Duplicate is tracked in duplicate conflict list instead of importing a second active song row. |
| Duplicate dialog stays open while files change externally | File Issues dialog is open | Live timer refresh updates duplicate/Missing/removed sections while open, so stale data is reduced. |
| Duplicate alternative file deleted while dialog is open | External delete removes one duplicate file path | Deleted duplicate entry is removed on refresh, so invalid choices no longer show. |
| User selected duplicate entry but selected file no longer exists before apply | Dialog closes and duplicate apply runs | App falls back to the best available duplicate option in that group when possible. |
| Current/original duplicate file is removed, alternatives still exist | Scan/dialog refresh runs fallback | App picks the best remaining option using filename similarity and keeps other alternatives in duplicate list. |
| Current/original duplicate file removed and no alternatives remain | Scan/dialog refresh runs fallback | Duplicate group is removed; if original path was tracked in library, normal Missing flow applies. |
| Remove one Missing entry from dialog | User clicks remove on one Missing row | DB rows with that path are removed safely, and the Missing UI entry is cleared. |
| Remove one Bad MIDI entry from dialog | User clicks remove on one Bad MIDI row | Matching DB rows by path are removed and the Bad/Missing UI entries for that path are cleared. |
| Remove All file errors | User clicks "Remove All" in File Issues | Missing and Bad rows are removed from DB by path and both lists are cleared in UI. |
When fallback is needed, the app chooses the candidate with the best filename similarity to the original path:
- Shared prefix length (higher is better)
- Shared filename token count (higher is better)
- Contains boost when one name contains the other
- Shorter filename-length difference as tie-breaker
- Alphabetical path order as final tie-breaker
This is why fallback usually feels like "choose the closest file name".
Playback behavior is designed to avoid uncontrolled input:
- Playback is scoped to intended usage context.
- Switching away from target context can stop playback.
- Listen mode and testing workflows allow pre-checking songs before use.
See How To Use and FAQ : General for more guidance.