Skip to content

How it Works

Jed556 edited this page Apr 3, 2026 · 3 revisions

Contents

  1. Playback Pipeline
  2. Key Offset Flow
  3. Default Key Offset vs Key Offset
  4. Auto-key Detection
  5. Transpose Flow (Ignore / Up / Down / Smart)
  6. Keypress Backends (Windows APIs / Libraries)
  7. Advanced File Handling Scenarios
  8. Playback Safety and Focus

Playback Pipeline

At a high level:

  1. Song is loaded and MIDI tracks are parsed.
  2. Track filters and song settings are applied.
  3. For every note event, key offset and transpose are applied.
  4. Converted note is mapped to a key for the selected instrument/layout.
  5. Playback emits keyboard events at runtime.
  6. 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;
}

Key Offsets

Key Offset Flow

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.

Default Key Offset vs Key Offset

This is the most important part to avoid confusion:

  1. Default Key Offset (Song.DefaultKey) This is the detected or assigned song key center (root reference).

  2. Key Offset (Song.Key) This is your per-song adjustment value.

  3. Effective Key Offset This is what playback actually uses: Effective = DefaultKey + KeyOffset (when DefaultKey exists).

If DefaultKey is null (legacy songs), KeyOffset behaves as an absolute value.

Example:

  • DefaultKey = +2 (D3)
  • KeyOffset = -1
  • EffectiveKeyOffset = +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 KeyOffset when DefaultKey exists.

Auto-key Detection

When Auto Detect Default Key is enabled, newly added songs can automatically get a detected default key center.

Flow summary:

  1. Try MIDI key signature first.
  2. If missing, fall back to pitch-class profile analysis.
  3. Normalize detected tonic around C3-style signed offsets.
  4. Store result in Song.DefaultKey and reset Song.Key to 0 (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.

Transposition (Ignore / Up / Down / Smart)

Ignore

No transposition is applied. Out-of-range handling depends on playback settings.

Up / Down

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

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:

  1. It first octave-folds extreme notes into the playable range.
  2. It estimates a best-fit scale/mode from current playable notes.
  3. It prefers notes inside that scale before falling back to all notes.
  4. 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.

Keypress Backends (Windows APIs / Libraries)

The app supports three keypress backends from Settings:

  1. Input Simulator Uses the InputSimulator library for global keyboard injection.
  2. Direct Input (SendInput) Uses Win32 SendInput API via P/Invoke.
  3. Window Message (PostMessage) Sends WM_KEYDOWN/WM_KEYUP directly 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 Simulator is the most general desktop-compatible path.

Advanced File Handling Scenarios

This section documents the current advanced behavior for auto-scan, exclusions, duplicates, missing files, and realtime dialog refresh.

Scenario Matrix

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.

Duplicate Fallback Rule

When fallback is needed, the app chooses the candidate with the best filename similarity to the original path:

  1. Shared prefix length (higher is better)
  2. Shared filename token count (higher is better)
  3. Contains boost when one name contains the other
  4. Shorter filename-length difference as tie-breaker
  5. Alphabetical path order as final tie-breaker

This is why fallback usually feels like "choose the closest file name".

Playback Safety and Focus

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.

Clone this wiki locally