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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
the editor never draws a connection the music doesn't have.
### Added

- **A MIDI file is a project now.** Dropping a `.mid`/`.midi` into New Song
used to dead-end: the file was noted, then forgotten, and Create stayed
disabled. Now a staged MIDI alone enables Create (the title pre-fills from
the filename), and Create opens straight into the track picker with the
file already parsed — no re-picking. And the picker **actually unpacks
multitrack MIDI**: every track you check imports as its own chart track,
named after the MIDI track ("Keys — Bass, Baby."), instead of silently
importing only the first. The MIDI's own tempo map is offered as the
project grid as before, and the temporary placeholder track the create
needs is cleaned up automatically once your real tracks land.
- **Import a Guitar Pro or MIDI file, press play, hear music.** Transcription
tracks now sound out of the box, including alongside recordings and stems:
**every track plays its instrument** (band mode defaults on), and the guide
Expand Down
56 changes: 44 additions & 12 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6298,19 +6298,49 @@ async def import_keys_midi(data: dict):
if not validated:
return JSONResponse({"error": "MIDI file not found"}, 400)
midi_path = str(validated)
if track_index is None:
return JSONResponse({"error": "track_index required"}, 400)
try:
track_index = int(track_index)
except (TypeError, ValueError):
return JSONResponse({"error": "track_index must be an integer"}, 400)
# BATCH form (multitrack unpack): `tracks` = [{index, channel_filter}]
# converts every entry BEFORE the one temp-dir cleanup — the legacy
# single-track body used to rmtree after its first conversion, which
# made a per-track request loop find no file on the second track.
batch_raw = data.get("tracks")
batch = []
if isinstance(batch_raw, list) and batch_raw:
for entry in batch_raw[:64]:
if not isinstance(entry, dict):
continue
try:
b_idx = int(entry.get("index"))
except (TypeError, ValueError):
continue
b_cf = entry.get("channel_filter")
try:
b_cf = None if (b_cf is None or b_cf == "") else int(b_cf)
except (TypeError, ValueError):
b_cf = None
batch.append((b_idx, b_cf))
if not batch:
return JSONResponse({"error": "tracks must carry integer indices"}, 400)
else:
if track_index is None:
return JSONResponse({"error": "track_index required"}, 400)
try:
track_index = int(track_index)
except (TypeError, ValueError):
return JSONResponse({"error": "track_index must be an integer"}, 400)
batch.append((track_index, channel_filter))

def _convert():
# Read the MIDI's own tempo/sig/beat grid in the SAME worker (the
# temp dir is rmtree'd right after this returns) so the client can
# offer it as the project grid (DAW 3.2). Shifted by audio_offset to
# match where the notes below land. Empty {} when unavailable.
tempo_map = _safe_midi_tempo_map(midi_path, track_index, audio_offset)
tempo_map = _safe_midi_tempo_map(midi_path, batch[0][0], audio_offset)
out = []
for b_idx, b_cf in batch:
out.append(_convert_one(b_idx, b_cf))
return out, tempo_map

def _convert_one(track_index, channel_filter):
wire = convert_midi_track_to_keys_wire(
midi_path, track_index, audio_offset, "Keys",
channel_filter=channel_filter,
Expand Down Expand Up @@ -6347,24 +6377,26 @@ def _convert():
"link_next": False,
},
})
return arr_data, tempo_map
return arr_data

try:
arr_data, tempo_map = await asyncio.get_event_loop().run_in_executor(None, _convert)
arr_list, tempo_map = await asyncio.get_event_loop().run_in_executor(None, _convert)
except Exception as e:
import traceback
traceback.print_exc()
return JSONResponse({"error": str(e)}, 500)

# Clean up the MIDI temp dir now that conversion is complete — the
# client no longer needs to reference midi_path after this response.
# 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}")

return {"arrangement": arr_data, "tempo_map": tempo_map}
# Legacy single-track callers read `arrangement`; the batch form reads
# `arrangements` (same order as the request's `tracks`).
return {"arrangement": arr_list[0], "arrangements": arr_list, "tempo_map": tempo_map}

# ── Convert GP tracks to arrangement and open in editor ──────────

Expand Down
115 changes: 112 additions & 3 deletions src/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,14 @@
createState = {
mode: 'blank',
roster: ['Lead'],
// Provenance for the default roster: false until the user actually
// edits the roster UI. The MIDI-only create keys its seed-placeholder
// decision on THIS, not on the roster's value — an untouched default
// is boilerplate, but an explicitly re-built ['Lead'] is intent.
rosterTouched: false,
gpPath: null, tracks: null, gpName: null, gpHasEmbedded: false, gpSyncCount: 0,
eofFiles: null, eofName: null,
audioUrl: null, audioName: null, audioDuration: null, audioFile: null, midiInfo: null,
audioUrl: null, audioName: null, audioDuration: null, audioFile: null, midiInfo: null, midiFiles: null,
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 @@ -666,6 +671,9 @@
if (!state || !flags) return false;
if (state.gpPath) return true;
if (state.eofFiles && state.eofFiles.length) return true;
// A staged MIDI alone creates a project (like a GP file — the title is
// defaulted from the filename at stage time, so the blank path can run).
if (state.midiFiles && state.midiFiles.length) return true;
var instruments = ['Lead', 'Rhythm', 'Keys', 'Bass', 'Drums'];
var hasInstrument = !!(state.roster && state.roster.some(function (r) {
return instruments.indexOf(r) >= 0;
Expand Down Expand Up @@ -762,6 +770,9 @@
}

function _toggleRosterRole(id) {
// Any add/remove (including remove-then-re-add of the default) is explicit
// intent — see rosterTouched in editorShowCreateModal.
createState.rosterTouched = true;
const i = createState.roster.indexOf(id);
if (i >= 0) createState.roster.splice(i, 1);
else createState.roster.push(id);
Expand Down Expand Up @@ -954,10 +965,68 @@
}
}

/* @pure:midi-create:start */
// The default project title for a MIDI-only create: the filename stem,
// underscores/dashes read as spaces. Never empty (the blank-create backend
// requires a title).
function _midiDefaultTitlePure(filename) {
const stem = String(filename || '').replace(/\.[^.]*$/, '');
const t = stem.replace(/[_-]+/g, ' ').trim();
return t || 'MIDI import';
}

// ONE MIDI slot: Create feeds exactly one file into the track picker, so the
// LAST pick wins — over a multi-select and over anything staged earlier — and
// the status says which file is staged and what it replaced. (Multi-file
// import is a Create-window redesign, not a silent first-file-wins.) Pure
// over filenames so the slot rule is unit-testable.
function _midiStageSlotPure(prevName, names) {
const name = String(names[names.length - 1] || '');
const dropped = names.slice(0, -1).map(String).filter((n) => n !== name);
if (prevName && prevName !== name && !dropped.includes(prevName)) dropped.unshift(String(prevName));
const status = dropped.length
? ('One MIDI per project for now — staged "' + name + '", replacing '
+ dropped.map((n) => '"' + n + '"').join(', ')
+ '. Create opens it and lets you pick which tracks to chart.')
: ('MIDI staged: "' + name + '" — Create opens it and lets you pick which tracks to chart.');
return { name, dropped, status };
}

// Should the MIDI create replace the modal roster with a removable placeholder
// seed? PROVENANCE-driven, not value-driven: an untouched roster is modal
// boilerplate whatever it contains, while a roster the user actually edited is
// intent and stays — even when they rebuilt the exact default. An emptied
// roster still gets the seed: the blank-create backend requires >=1
// arrangement. 'Lead' (not Keys) because that backend accepts Lead/Rhythm/Bass
// rosters only today — the placeholder is removed post-import anyway, so its
// instrument never matters.
function _midiSeedRosterPure(roster, touched) {
const r = Array.isArray(roster) ? roster.slice() : [];
if (r.length && touched) return { roster: r, seeded: false };
return { roster: ['Lead'], seeded: true };
}
/* @pure:midi-create:end */
export { _midiDefaultTitlePure, _midiStageSlotPure, _midiSeedRosterPure };

function _stageMidi(files) {
createState.midiInfo = files.length === 1 ? files[0].name : (files.length + ' MIDI files');
// Single slot — staging again REPLACES what was staged (and the status
// admits it) instead of silently keeping only files[0] at Create time.
const prevName = (createState.midiFiles && createState.midiFiles[0])
? createState.midiFiles[0].name : null;
const file = files[files.length - 1];
const slot = _midiStageSlotPure(prevName, files.map((f) => f.name));
createState.midiInfo = slot.name;
// Keep the FILE, not just its name — Create feeds it straight into the
// track picker so the user never has to re-pick what they just staged.
createState.midiFiles = [file];
// A MIDI alone is a real project now: default the title from the file so
// the (title-requiring) create path can run — visible and editable.
const titleEl = document.getElementById('editor-create-title');
if (titleEl && !titleEl.value.trim()) {
titleEl.value = _midiDefaultTitlePure(file && file.name);
}
const iStatus = document.getElementById('editor-create-import-status');
if (iStatus) iStatus.textContent = 'MIDI added — after Create, add Keys / Drums from it in the editor (+Keys / +Drums).';
if (iStatus) iStatus.textContent = slot.status;
}

// Grey the YouTube field while a master-audio FILE is staged (file wins).
Expand Down Expand Up @@ -1070,6 +1139,7 @@
_refreshGpAudioUI();
} else if (role === 'midi') {
createState.midiInfo = null;
createState.midiFiles = null;
}
const iStatus = document.getElementById('editor-create-import-status');
if (iStatus) iStatus.textContent = '';
Expand Down Expand Up @@ -1584,7 +1654,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 1657 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 @@ -1757,7 +1827,7 @@
createState.lastSync = { ...createState.lastSync, ...data };
}
return data;
} catch (e) {

Check warning on line 1830 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 @@ -1827,13 +1897,52 @@
// dead definition is removed, not promoted. Restoring its intent (roster
// validation, and audio optional for a draft-now project) is a product change
// and belongs in its own PR.
// MIDI-only create: a blank (draft) create seeded with a Keys placeholder,
// then the staged file feeds straight into the Add-Keys track picker — the
// user picks which of the MIDI's tracks to chart, each imports as its own
// arrangement, and the placeholder is removed once real tracks landed.
async function _editorDoMidiCreate() {
const titleEl = document.getElementById('editor-create-title');
if (titleEl && !titleEl.value.trim()) {
titleEl.value = _midiDefaultTitlePure(
createState.midiFiles[0] && createState.midiFiles[0].name);
}
// The blank-create backend requires >=1 seeded arrangement. Whether the
// modal's roster is boilerplate is a PROVENANCE question, not a value
// one: rosterTouched flips the moment the user edits the roster UI, so an
// untouched default is replaced with a flagged placeholder the import
// cleans up, while an explicitly-built roster — even one equal to the
// default ['Lead'] — is real intent and stays (their parts + the MIDI
// tracks).
const seedPlan = _midiSeedRosterPure(createState.roster, createState.rosterTouched);
createState.roster = seedPlan.roster;
const seeded = seedPlan.seeded;
const file = createState.midiFiles[0];
await _editorDoBlankCreate();
if (!S.sessionId || !S.arrangements.length) return; // create failed — its status stands
// Bind the seed flag to THIS session. If the user cancels the picker
// below (no import), _maybeRemoveMidiSeed never runs and the flag survives
// on shared S — loadCDLC doesn't clear it — so without the session tag a
// later import on a DIFFERENT song would delete that song's arrangement 0.
if (seeded) { S._midiSeedArrIdx = 0; S._midiSeedSession = S.sessionId; }
// Feed the staged file straight into the existing track picker (the one
// surface that already knows channels/pairs/drums) — no re-pick.
if (typeof window.editorShowAddKeysModal === 'function'
&& typeof window._editorKeysHandleFile === 'function') {
window.editorShowAddKeysModal();
window._editorKeysHandleFile(file);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export async function editorDoCreate() {
// One menu, no mode toggle — route on what was provided: a Guitar Pro file
// wins, then EOF XML arrangement(s), else a from-scratch (draft) create
// (only a title is required; audio + artist are optional).
if (!createState.gpPath) {
if (createState.eofFiles && createState.eofFiles.length) {
await _editorDoEofCreate();
} else if (createState.midiFiles && createState.midiFiles.length) {
await _editorDoMidiCreate();
} else {
await _editorDoBlankCreate();
}
Expand Down
Loading
Loading