feat: implement md/41_INSTRUMENT.md spec

- PianoRollTabService: getParentTrackByItemId, buildActiveScope,
  getTrackMidiChannel helpers
- parent_track_id on all MIDI items for reverse track lookup
- handleSwitchMidiItem uses active_scope pattern
- Toolbar shows parent track context labels
- Channel assignment simplified to trackIndex % 16
- All playNote calls use unified getTrackMidiChannel
This commit is contained in:
2026-07-27 20:48:14 +07:00
parent 40502eda86
commit b42190e6bc
5 changed files with 96 additions and 72 deletions
+33 -59
View File
@@ -4624,6 +4624,13 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
var trk = activeTracks.find(function(t) { return t.id === st.trackId; });
return trk ? (trk.midiItems || []).find(function(m) { return m.id === st.target_id; }) : null;
}, [activeTracks, st.trackId, st.target_id]);
var activeParentTrackName = '';
if (st.target_id && activeTracks) {
var aptTrk = window.SonicPianoRoll ? window.SonicPianoRoll.getParentTrackByItemId(st.target_id, activeTracks) : null;
if (!aptTrk) aptTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
if (!aptTrk && activeTargetItem) aptTrk = activeTracks.find(function(t) { return (t.midiItems || []).some(function(m) { return m.id === st.target_id; }); });
if (aptTrk) activeParentTrackName = aptTrk.name || aptTrk.id;
}
const sessionStartBar = 0;
const renderBeatOffset = sessionSyncMode && activeTargetItem
? (activeTargetItem.startTime / secondsPerBar) * timeSigNum : 0;
@@ -4643,7 +4650,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (itemId === st.target_id) return;
var match = allMidiItems.find(function(m) { return m.id === itemId; });
if (!match) return;
var trk = (activeTracks || []).find(function(t) { return t.id === match._trackId; });
var scope = window.SonicPianoRoll ? window.SonicPianoRoll.buildActiveScope(itemId, activeTracks) : null;
var trk = scope ? null : (activeTracks || []).find(function(t) { return t.id === match._trackId; });
var newBeatOff = (match.startTime / secondsPerBar) * timeSigNum;
var spb = 60.0 / (parseInt(bpm) || 120);
var newTime = 0;
@@ -4651,13 +4659,14 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
return prev.map(function(s) {
if (s.id !== st.id) return s;
return Object.assign({}, s, {
trackId: match._trackId,
trackId: scope ? scope.parent_track_id : (match._trackId || trk?.id),
target_id: match.id,
label: 'Piano Roll: ' + (match.name || 'MIDI'),
notes: match.notes || [],
duration: match.duration || 4,
instrumentProgram: trk ? trk.instrumentProgram : undefined,
instrumentName: trk ? trk.instrumentName : undefined,
instrumentProgram: scope ? scope.instrument_program : (trk ? trk.instrumentProgram : undefined),
instrumentName: scope ? scope.instrument_name : (trk ? trk.instrumentName : undefined),
active_scope: scope || null,
note_selection: [],
currentTime: newTime
});
@@ -4843,11 +4852,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
currentBeat < n.start_beat && newBeat >= n.start_beat
);
var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var pvCh = pvTrk ? pvTrk.midiChannel : undefined;
if (pvCh === undefined && pvTrk) {
for (var pvi = 0; pvi < activeTracks.length; pvi++) { if (activeTracks[pvi].id === st.trackId) { pvCh = pvi >= 9 ? pvi + 1 : pvi; break; } }
if (pvCh === undefined || pvCh >= 16) pvCh = 0;
}
var pvCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pvTrk, activeTracks) : (pvTrk ? pvTrk.midiChannel : 0);
playing.forEach(n => {
window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, st.instrumentProgram, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined);
});
@@ -5125,11 +5130,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
if (window.SonicSF) {
const ctx = getAudioContext();
var clTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var clCh = clTrk ? clTrk.midiChannel : undefined;
if (clCh === undefined && clTrk) {
for (var cli = 0; cli < activeTracks.length; cli++) { if (activeTracks[cli].id === st.trackId) { clCh = cli >= 9 ? cli + 1 : cli; break; } }
if (clCh === undefined || clCh >= 16) clCh = 0;
}
var clCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(clTrk, activeTracks) : (clTrk ? clTrk.midiChannel : 0);
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null, clCh, clTrk ? clTrk.synth_engine : undefined);
}
}
@@ -5653,11 +5654,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
const renderKeybed = () => {
var kbTrk = activeTracks.find(function(t) { return t.id === st.trackId; });
var kbCh = kbTrk ? kbTrk.midiChannel : undefined;
if (kbCh === undefined && kbTrk) {
for (var kbi = 0; kbi < activeTracks.length; kbi++) { if (activeTracks[kbi].id === st.trackId) { kbCh = kbi >= 9 ? kbi + 1 : kbi; break; } }
if (kbCh === undefined || kbCh >= 16) kbCh = 0;
}
var kbCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(kbTrk, activeTracks) : (kbTrk ? kbTrk.midiChannel : 0);
var kbSynth = kbTrk ? kbTrk.synth_engine : undefined;
const keys = [];
for (let pitch = 127; pitch >= PITCH_START; pitch--) {
@@ -5892,7 +5889,9 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
return React.createElement("option", { key: m.id, value: m.id },
(m._trackName || '') + ' - ' + (m.name || 'MIDI')
);
})), React.createElement("div", {
})), activeParentTrackName ? React.createElement("span", {
className: "text-[9px] text-zinc-500 ml-1"
}, "(Belongs to: ", React.createElement("span", { className: "text-zinc-400 font-semibold" }, activeParentTrackName), ")") : null, React.createElement("div", {
className: "flex items-center gap-1 text-xs"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold"
@@ -5923,8 +5922,8 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
}, React.createElement("option", { value: "" }, "Input"), React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), React.createElement("button", {
onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId),
title: st.instrumentName || "Synth",
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[50px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
}, React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), React.createElement("span", { className: "truncate text-[9px]" }, st.instrumentName || 'Synth')), React.createElement("div", {
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[70px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
}, React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), React.createElement("span", { className: "truncate text-[9px]" }, activeParentTrackName ? ('(' + activeParentTrackName + ') ' + (st.instrumentName || 'Synth')) : (st.instrumentName || 'Synth'))), React.createElement("div", {
className: "flex items-center gap-1 ml-1 text-xs"
}, React.createElement("span", { className: "text-zinc-500" }, "AI:"), React.createElement("input", {
type: "number", value: aiBarStart, onChange: e => setAiBarStart(parseInt(e.target.value) || 0),
@@ -6365,6 +6364,7 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => {
midiItems.push({
id: item.id,
name: item.name,
parent_track_id: t.id,
startTime: item.start_bar * secondsPerBar,
duration: item.duration_bars * secondsPerBar,
length_bars: item.duration_bars || 4,
@@ -6797,8 +6797,7 @@ const App = () => {
var mt = activeTracksRef.current || tracks;
var midx = 0;
for (var mi = 0; mi < mt.length; mi++) { if (mt[mi].id === trackId) { midx = mi; break; } }
var mch = sfBank === 128 ? 9 : (midx >= 9 ? midx + 1 : midx);
if (mch >= 16) mch = 0;
var mch = sfBank === 128 ? 9 : (midx % 16);
return { ...t, midiChannel: mch, instrumentId, instrumentProgram: sfProg, instrumentName: displayName, soundfont_bank: sfBank, soundfont_program: sfProg, synth_engine: synthEngine, type: hasInstrument ? 'MIDI' : (t.type === 'MIDI' ? 'audio' : t.type) };
}));
setInstrumentDropdownTrackId(null);
@@ -6812,8 +6811,7 @@ const App = () => {
var allTracks = activeTracksRef.current || tracks;
var tidx = 0;
for (var i = 0; i < allTracks.length; i++) { if (allTracks[i].id === trackId) { tidx = i; break; } }
var ch = sfBank === 128 ? 9 : (tidx >= 9 ? tidx + 1 : tidx);
if (ch >= 16) ch = 0;
var ch = sfBank === 128 ? 9 : (tidx % 16);
// Store channel for consistent per-track instrument playback
if (!allTracks[tidx] || allTracks[tidx].midiChannel === undefined) {
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiChannel: ch } : t));
@@ -10133,13 +10131,7 @@ const App = () => {
const track = activeTracks.find(t => t.id === trackId);
const destNode = getOrCreateTrackNode(track, context);
const program = track ? track.instrumentProgram : undefined;
var prevCh = track ? track.midiChannel : undefined;
if (prevCh === undefined && track) {
for (var pi = 0; pi < activeTracks.length; pi++) {
if (activeTracks[pi].id === track.id) { prevCh = pi >= 9 ? pi + 1 : pi; break; }
}
if (prevCh === undefined || prevCh >= 16) prevCh = 0;
}
var prevCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, activeTracks) : (track ? track.midiChannel : 0);
window.SonicSF.playNote(
pitch,
velocity,
@@ -10191,16 +10183,7 @@ const App = () => {
// MIDI items playback
const midiItems = track.midiItems || [];
if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) {
var trkCh = track.midiChannel;
if (trkCh === undefined) {
for (var tci = 0; tci < activeTracks.length; tci++) {
if (activeTracks[tci].id === track.id) {
trkCh = tci >= 9 ? tci + 1 : tci;
break;
}
}
if (trkCh === undefined || trkCh >= 16) trkCh = 0;
}
var trkCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, activeTracks) : (track.midiChannel !== undefined ? track.midiChannel : 0);
const bpmVal = parseInt(bpm) || 120;
const secondsPerBeat = 60.0 / bpmVal;
midiItems.forEach(item => {
@@ -10325,11 +10308,7 @@ const App = () => {
if (noteStartMain >= secStart && noteStartMain < secEnd && offsetTime < noteEndMain) {
const notePlayEndMain = Math.min(noteEndMain, secEnd);
const program = subTrack.instrumentProgram !== undefined ? subTrack.instrumentProgram : 0;
var subCh = subTrack.midiChannel;
if (subCh === undefined) {
subCh = subIdx >= 9 ? subIdx + 1 : subIdx;
}
if (subCh === undefined || subCh >= 16) subCh = 0;
var subCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(subTrack, secTracks) : (subTrack.midiChannel !== undefined ? subTrack.midiChannel : (subIdx % 16));
if (offsetTime < noteStartMain) {
const delay = noteStartMain - offsetTime;
@@ -10458,15 +10437,7 @@ const App = () => {
const instrumentProgram = track ? track.instrumentProgram : undefined;
const synthEngine = track ? track.synth_engine : undefined;
var allTracks = activeTracksRef.current || [];
var mainCh = 0;
if (track) {
if (track.midiChannel !== undefined) {
mainCh = track.midiChannel;
} else {
for (var mc = 0; mc < allTracks.length; mc++) { if (allTracks[mc].id === track.id) { mainCh = mc >= 9 ? mc + 1 : mc; break; } }
if (mainCh >= 16) mainCh = 0;
}
}
var mainCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, allTracks) : (track ? track.midiChannel : 0);
// Compute session beat offset from target item
var sessionBeatOffset = 0;
var targetTrk = allTracks.find(function(t) { return t.id === st.trackId; });
@@ -10502,8 +10473,7 @@ const App = () => {
if (ghostTrack.midiChannel !== undefined) {
ghostCh = ghostTrack.midiChannel;
} else {
for (var gc = 0; gc < allTracks.length; gc++) { if (allTracks[gc].id === ghostTrack.id) { ghostCh = gc >= 9 ? gc + 1 : gc; break; } }
if (ghostCh >= 16) ghostCh = 0;
ghostCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ghostTrack, allTracks) : (ghostTrack ? ghostTrack.midiChannel : 0);
}
}
layer.notes.forEach(function(note) {
@@ -11005,6 +10975,7 @@ const App = () => {
const newMidiItem = {
id: 'midi_rec_' + Date.now(),
name: 'Recorded MIDI',
parent_track_id: t.id,
startTime: recordingStartTimeRef.current,
duration: Math.ceil(totalDurationBeats / 4) * secondsPerBar,
length_bars: Math.ceil(totalDurationBeats / 4),
@@ -11025,6 +10996,7 @@ const App = () => {
const newMidiItem = {
id: 'midi_rec_' + Date.now(),
name: 'Recorded MIDI',
parent_track_id: trackId,
startTime: recordingStartTimeRef.current,
duration: Math.ceil(totalDurationBeats / 4) * secondsPerBar,
length_bars: Math.ceil(totalDurationBeats / 4),
@@ -14294,6 +14266,7 @@ const App = () => {
const newMidiItem = {
id: 'item_ai_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5),
name: `${composition_title || 'AI Theme'} - ${aiTrack.track_name}`,
parent_track_id: targetTrack.id,
startTime: itemStartTimeSec,
duration: durationSec,
length_bars: total_bars,
@@ -14339,6 +14312,7 @@ const App = () => {
const midiItem = {
id: `midi_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
name: 'MIDI Item',
parent_track_id: trackId,
startTime: startBar * secondsPerBar,
duration: lengthBars * secondsPerBar,
notes: [],
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
(function () {
'use strict';
window.SonicPianoRoll = {
getParentTrackByItemId: function (itemId, tracks) {
if (!tracks || !itemId) return null;
for (var i = 0; i < tracks.length; i++) {
var items = tracks[i].midiItems || [];
for (var j = 0; j < items.length; j++) {
if (items[j].id === itemId) return tracks[i];
}
}
return null;
},
getParentTrackIdByItemId: function (itemId, tracks) {
var trk = this.getParentTrackByItemId(itemId, tracks);
return trk ? trk.id : null;
},
buildActiveScope: function (itemId, tracks) {
var parentTrack = this.getParentTrackByItemId(itemId, tracks);
if (!parentTrack) return null;
var trackIndex = -1;
for (var i = 0; i < tracks.length; i++) {
if (tracks[i].id === parentTrack.id) { trackIndex = i; break; }
}
return {
item_id: itemId,
parent_track_id: parentTrack.id,
midi_channel: trackIndex >= 0 ? trackIndex % 16 : 0,
current_synth_engine: parentTrack.synth_engine || null,
instrument_program: parentTrack.instrumentProgram,
instrument_name: parentTrack.instrumentName
};
},
getTrackMidiChannel: function (track, tracks) {
if (track && track.midiChannel !== undefined) return track.midiChannel;
if (!track || !tracks) return 0;
for (var i = 0; i < tracks.length; i++) {
if (tracks[i].id === track.id) return i % 16;
}
return 0;
}
};
})();
+1
View File
@@ -18,6 +18,7 @@
<script src="/static/js/services/soundfontPlayer.js?v=202607271245"></script>
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
<script src="/static/js/app.precompiled.js?v=202607271245" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">