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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Creating a project from a multi-track MIDI no longer silently loses the
drums.** Core's `list_midi_tracks` excludes channel-9 (a keys-import of a
drum channel is empty), so a full-band MIDI's drum track never reached the
create picker and vanished. `/import-midi` now also returns the channel-9
`drum_tracks` (via `list_drum_tracks`), and the create flow auto-imports each
as its own `type:"drums"` arrangement — the same auto-split the Guitar Pro
create-import already does — so drums arrive alongside the pitched parts
instead of disappearing. The pitched picker and its import are unchanged
(drums ride a separate list, so no picker-row/selection collision); a
drums-only MIDI still imports as a drums session. `/import-drums-midi` now
accepts the create-flow upload path too, and the drum-tab stash step is
shared with the Add-Drums modal (`_stashImportedDrumTab`). Covered by
`tests/midi_drum_autosplit.test.mjs` and runtime-verified end-to-end.

- **A drum edit no longer strips another tool's authored keys from drum-part
manifest entries.** Saving with drum changes rebuilds the `type: drums`
arrangement entries; that rebuild now merges onto the prior same-id entry —
Expand Down
56 changes: 38 additions & 18 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7169,7 +7169,7 @@ async def parse_goplayalong_sync(file: UploadFile = File(...)):
@app.post("/api/plugins/editor/import-midi")
async def import_midi(file: UploadFile = File(...)):
"""Upload a MIDI file and return track listing."""
from lib.midi_import import list_midi_tracks
from lib.midi_import import list_midi_tracks, list_drum_tracks

# Validate extension — the browser accept filter is advisory only.
orig_suffix = Path(file.filename or "").suffix.lower()
Expand Down Expand Up @@ -7201,13 +7201,25 @@ async def import_midi(file: UploadFile = File(...)):
def _list():
return list_midi_tracks(midi_path)

# Core's `list_midi_tracks` EXCLUDES channel-9 (GM percussion) — a
# keys-import of a drum channel would yield an empty arrangement, so
# drums are dropped from the pitched listing. List them separately so
# the create picker can auto-split them into `type:"drums"` parts
# instead of silently losing a full-band MIDI's drums.
def _list_drums():
try:
return list_drum_tracks(midi_path)
except Exception:
return []

try:
tracks = await asyncio.get_event_loop().run_in_executor(None, _list)
drum_tracks = await asyncio.get_event_loop().run_in_executor(None, _list_drums)
except Exception as e:
shutil.rmtree(tmp, ignore_errors=True)
return JSONResponse({"error": f"Failed to parse MIDI file: {e}"}, 500)

return {"midi_path": midi_path, "tracks": tracks}
return {"midi_path": midi_path, "tracks": tracks, "drum_tracks": drum_tracks}

# ── MIDI import: convert a track to a Keys arrangement ────────────

Expand Down Expand Up @@ -7326,13 +7338,14 @@ def _convert_one(track_index, channel_filter):
traceback.print_exc()
return JSONResponse({"error": str(e)}, 500)

# Clean up the MIDI temp dir now that EVERY conversion is complete —
# the client no longer needs to reference midi_path after this response.
try:
shutil.rmtree(Path(midi_path).parent)
except OSError as _cleanup_err:
import warnings
warnings.warn(f"Could not clean up MIDI temp dir: {_cleanup_err}")
# A create flow that also selected drums reuses this staged file for
# the following drum conversions. Its last consumer owns cleanup.
if not data.get("keep_upload"):
try:
shutil.rmtree(Path(midi_path).parent)
except OSError as _cleanup_err:
import warnings
warnings.warn(f"Could not clean up MIDI temp dir: {_cleanup_err}")

# Legacy single-track callers read `arrangement`; the batch form reads
# `arrangements` (same order as the request's `tracks`).
Expand Down Expand Up @@ -8459,7 +8472,13 @@ async def import_drums_midi(data: dict):
except (TypeError, ValueError):
return JSONResponse({"error": "audio_offset must be a number"}, 400)

validated = _validate_editor_upload_path(midi_path_raw, "slopsmith_drums_midi_")
# Accept both the Add-Drums list upload (slopsmith_drums_midi_) and the
# generic create-flow MIDI upload (slopsmith_midi_): the create picker's
# auto drum-split reuses the file it already staged via /import-midi.
validated = (
_validate_editor_upload_path(midi_path_raw, "slopsmith_drums_midi_")
or _validate_editor_upload_path(midi_path_raw, "slopsmith_midi_")
)
if not validated:
return JSONResponse({"error": "MIDI file not found"}, 400)
midi_path = str(validated)
Expand Down Expand Up @@ -8514,14 +8533,15 @@ def _convert():
# swept by the opportunistic TTL cleanup, same as import_keys_midi.
return JSONResponse({"error": str(e)}, 500)

# Clean up the MIDI temp dir now that conversion is complete — mirrors
# import_keys_midi which also rmtrees after a successful conversion so
# temp dirs don't accumulate between TTL cleanup runs.
try:
shutil.rmtree(_midi_tmp_dir)
except OSError as _cleanup_err:
import warnings
warnings.warn(f"Could not clean up drums MIDI temp dir: {_cleanup_err}")
# Multi-track create import reuses one staged file. Earlier conversions
# retain it; the final request removes it. Add-Drums remains the single
# consumer and therefore keeps the default cleanup behavior.
if not data.get("keep_upload"):
try:
shutil.rmtree(_midi_tmp_dir)
except OSError as _cleanup_err:
import warnings
warnings.warn(f"Could not clean up drums MIDI temp dir: {_cleanup_err}")

# Defensive .get() reads — if a future converter shape ever leaves
# `count` or `times` partially populated, return zero/empty rather
Expand Down
101 changes: 77 additions & 24 deletions src/arrangement.js
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,76 @@ export async function editorDrumsFileSelected(input) {
// scaffolds might still wire it up. Forwards to the new dispatcher.
export function editorDrumsGPSelected(input) { return editorDrumsFileSelected(input); }

// Shared "stash a freshly-imported drum tab onto the session" step, used by
// both the Add-Drums modal and the create-flow MIDI auto-split. Sorts hits,
// adds a NEW type:"drums" arrangement when a drums part already exists (else
// this tab becomes the primary), re-points S.drumTab at it, materializes it
// beside a pitched part (drums never sit at index 0), and lands it as a
// selected region at `placeAt` ('keep' = source timing). Returns whether it
// was ADDED as an extra part (vs. becoming the first/primary tab).
export function _stashImportedDrumTab(tab, placeAt = 'keep') {
if (tab && Array.isArray(tab.hits)) {
tab.hits.sort((a, b) => (a.t || 0) - (b.t || 0));
}
const added = _canAddAnotherDrums();
if (added) addDrumArrangement(S, tab); // its own type:"drums" arrangement
S.drumTab = tab; // the imported part is now the grid target
S.drumTabDirty = true; // user-imported — persist on next save
S.drumSel = new Set();
// Reflect the imported tab in S.arrangements[] — beside a pitched part
// only (a drums-only session must not put drums at index 0).
if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S);
host.placeImportedPartAsRegion({
kind: 'drums',
arrIdx: activeDrumArrangementIndex(S.arrangements, tab),
placeAt,
items: Array.isArray(tab.hits) ? tab.hits.slice() : [],
});
return added;
}

// Create-window MIDI auto-split. Core's `list_midi_tracks` drops channel-9, so
// the pitched picker never sees a MIDI's drums; /import-midi now also returns
// the channel-9 `drum_tracks`, and this imports each as its own type:"drums"
// part so a full-band MIDI keeps its drums instead of silently losing them
// (mirrors the GP create-import auto-extract). Must run AFTER the pitched
// import so each drum part has a pitched sibling to materialize beside. A bad
// track is skipped, not fatal. Returns how many parts were imported.
export async function importMidiDrumTracksIntoSession(midiPath, drumTracks, statusEl = null) {
if (!midiPath || !S.sessionId || !Array.isArray(drumTracks) || !drumTracks.length) return 0;
const picked = drumTracks.filter(t => t && Number(t.notes) > 0);
let imported = 0;
for (let i = 0; i < picked.length; i++) {
const t = picked[i];
try {
const resp = await fetch('/api/plugins/editor/import-drums-midi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
midi_path: midiPath,
track_index: Number(t.index) || 0,
audio_offset: host.effectiveAudioOffset(),
arrangement_name: t.name || 'Drums',
keep_upload: i < picked.length - 1,
}),
});
const data = await resp.json();
if (data.error || !data.drum_tab) continue;
_stashImportedDrumTab(data.drum_tab, 'keep');
imported++;
} catch (_) { /* skip this track, keep importing the rest */ }
}
if (imported > 0) {
host.updateArrangementSelector();
host.draw();
if (statusEl) {
const base = statusEl.textContent ? statusEl.textContent.trim() + ' ' : '';
statusEl.textContent = `${base}+ ${imported} drum part${imported === 1 ? '' : 's'} imported.`;
}
}
return imported;
}

export async function editorDoAddDrums() {
if (!_addDrumsFile || !S.sessionId) return;

Expand Down Expand Up @@ -495,32 +565,15 @@ export async function editorDoAddDrums() {
// any stale selection so indices from the old tab don't point into
// the new hits array. When drums already exist (saved sloppak), the
// import ADDS another drum part; create mode still replaces.
// Stash the imported tab: sort hits, add it as an extra part (or make
// it the primary), materialize beside a pitched part, land as a region.
// The dialog's Place-at row picks where it lands; create mode forces
// 'keep' (its Place-at row is hidden — placement is deferred until the
// create build path proves it round-trips regions[]).
const tab = data.drum_tab;
if (tab && Array.isArray(tab.hits)) {
tab.hits.sort((a, b) => (a.t || 0) - (b.t || 0));
}
const added = _canAddAnotherDrums();
if (added) addDrumArrangement(S, tab); // its own type:"drums" arrangement
S.drumTab = tab; // the imported part is now the grid target
S.drumTabDirty = true; // user-imported — persist on next save
S.drumSel = new Set();
// Reflect the imported tab in S.arrangements[] — beside a pitched
// part only (a drums-only session must not put drums at index 0).
if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S);

// Land the fresh part in the Tracks view as a selected region — placed
// at bar 1 / the playhead when the dialog said so, else left at the
// source file's own timing (R3b import-into-existing). Create mode
// forces 'keep' (its Place-at row is hidden — placement is deferred
// until the create build path proves it round-trips regions[]), so
// there the import lands selected at source timing.
const placeSel = document.getElementById('editor-add-drums-place');
host.placeImportedPartAsRegion({
kind: 'drums',
arrIdx: activeDrumArrangementIndex(S.arrangements, tab),
placeAt: (placeSel && !S.createMode) ? placeSel.value : 'keep',
items: Array.isArray(tab.hits) ? tab.hits.slice() : [],
});
const added = _stashImportedDrumTab(
tab, (placeSel && !S.createMode) ? placeSel.value : 'keep');

editorHideAddDrumsModal();
const hitCount = Array.isArray(data.drum_tab.hits)
Expand Down
30 changes: 26 additions & 4 deletions src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
} from './annotation-lanes.js';
import { _handshapesAreDirty, flattenChords, reconstructChords } from './chords.js';
import { isDrumArrangement, pitchedArrangementCount, syncDrumArrangement } from './drum-arrangement.js';
import { importMidiDrumTracksIntoSession } from './arrangement.js';
import { EditHistory } from './history.js';
import { host } from './host.js';
import { isKeysMode, updatePianoRange } from './keys.js';
Expand Down Expand Up @@ -149,7 +150,7 @@
musicXmlFile: null, musicXmlData: null, musicXmlName: null,
audioUrl: null, audioName: null, audioDuration: null, audioFile: null,
audioTracks: [], guideTrackId: '', youtubeSelected: true,
midiInfo: null, midiFiles: null, midiPath: null, midiTracks: [],
midiInfo: null, midiFiles: null, midiPath: null, midiTracks: [], midiDrumTracks: [],
artPath: null, previewPath: null,
gp8AudioMode: 'none', autoSyncAudioUrl: null, lastSync: null, autoSyncCoupled: false,
// GoPlayAlong sync sidecar (goplayalong.com): a <track> .xml that carries
Expand Down Expand Up @@ -1083,6 +1084,7 @@
createState.midiFiles = [file];
createState.midiPath = null;
createState.midiTracks = [];
createState.midiDrumTracks = [];
try {
const form = new FormData();
form.append('file', file);
Expand All @@ -1094,6 +1096,13 @@
...track,
selected: Number(track.notes) > 0,
}));
// Channel-9 drums arrive on a separate list — list_midi_tracks omits
// them (a keys-import of channel 9 is empty), so the backend lists them
// via list_drum_tracks; auto-imported as type:"drums" parts on Create.
createState.midiDrumTracks = (data.drum_tracks || []).map(track => ({
...track,
selected: Number(track.notes) > 0,
}));
} catch (error) {
const iStatus = document.getElementById('editor-create-import-status');
if (iStatus) iStatus.textContent = 'MIDI read failed: ' + error.message;
Expand Down Expand Up @@ -1185,6 +1194,7 @@
createState.midiFiles = null;
createState.midiPath = null;
createState.midiTracks = [];
createState.midiDrumTracks = [];
}
const iStatus = document.getElementById('editor-create-import-status');
if (iStatus) iStatus.textContent = '';
Expand Down Expand Up @@ -1701,7 +1711,7 @@
// Left in place rather than deleted, because deleting them is a separate change
// from the bug fix that made them redundant. They arrived with the same
// half-wired Create-New redesign (977ec65, #45).
function _populateCreateArrButtons() {

Check warning on line 1714 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'_populateCreateArrButtons' is defined but never used
const wrap = document.getElementById('editor-create-arr-buttons');
if (!wrap) return;
wrap.replaceChildren();
Expand Down Expand Up @@ -1902,7 +1912,7 @@
createState.lastSync = { ...createState.lastSync, ...data };
}
return data;
} catch (e) {

Check warning on line 1915 in src/create.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
return null;
}
}
Expand Down Expand Up @@ -1997,9 +2007,21 @@
// tag a later import on a DIFFERENT song would delete that song's arrangement 0.
if (seeded) { S._midiSeedArrIdx = 0; S._midiSeedSession = S.sessionId; }
const picked = (createState.midiTracks || []).filter(track => track.selected !== false && Number(track.notes) > 0);
if (createState.midiPath && picked.length) {
await importMidiTracksIntoSession(createState.midiPath, picked,
document.getElementById('editor-create-status'));
// Core's list_midi_tracks drops channel-9, so drums arrive on a separate
// list — import them alongside the pitched picks so a full-band MIDI keeps
// its drums instead of silently losing them.
const drumPicked = (createState.midiDrumTracks || []).filter(track => track.selected !== false && Number(track.notes) > 0);
const midiStatusEl = document.getElementById('editor-create-status');
if (createState.midiPath && (picked.length || drumPicked.length)) {
// Pitched first so each drum part has a pitched sibling to sit beside.
if (picked.length) {
await importMidiTracksIntoSession(createState.midiPath, picked, midiStatusEl, {
keepUpload: drumPicked.length > 0,
});
}
if (drumPicked.length) {
await importMidiDrumTracksIntoSession(createState.midiPath, drumPicked, midiStatusEl);
}
} else if (typeof window.editorShowAddKeysModal === 'function'
&& typeof window._editorKeysHandleFile === 'function') {
// Compatibility fallback for a core that could not list the MIDI at
Expand Down
3 changes: 2 additions & 1 deletion src/import.js
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ async function _maybeRemoveMidiSeed() {

// Create-window MIDI path: the unified table already listed and selected the
// file's tracks, so import those exact rows without reopening a second picker.
export async function importMidiTracksIntoSession(midiPath, pickedList, statusEl = null) {
export async function importMidiTracksIntoSession(midiPath, pickedList, statusEl = null, opts = {}) {
if (!midiPath || !S.sessionId || !Array.isArray(pickedList) || !pickedList.length) return false;
if (statusEl) statusEl.textContent = 'Importing selected MIDI tracks…';
try {
Expand All @@ -403,6 +403,7 @@ export async function importMidiTracksIntoSession(midiPath, pickedList, statusEl
body: JSON.stringify({
midi_path: midiPath,
audio_offset: host.effectiveAudioOffset(),
keep_upload: opts.keepUpload === true,
tracks: pickedList.map(track => ({
index: Number(track.index) || 0,
channel_filter: track.channel_filter == null ? null : Number(track.channel_filter),
Expand Down
Loading
Loading