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:
+33
-59
@@ -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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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">
|
||||
|
||||
@@ -479,3 +479,9 @@
|
||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (lines 4846, 5121, 5661-5671, 10117-10124, 10179-10201, 10285-10307)
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: main session play 2 tracks MIDI khác instrument → mỗi track giữ instrument riêng. Piano Roll preview note → không ảnh hưởng track khác.
|
||||
---
|
||||
|
||||
### [2026-07-27 20:44] Task: Apply md/41_INSTRUMENT.md spec to codebase
|
||||
- **Tóm tắt thay đổi:** Implement spec: (1) Tạo `pianoRollTabService.js` với `getParentTrackByItemId`, `buildActiveScope`, `getTrackMidiChannel`; (2) Thêm `parent_track_id` vào tất cả MIDI items creation (deserialize, recording, AI, API); (3) `handleSwitchMidiItem` dùng `buildActiveScope` pattern — thêm `active_scope` vào sub-tab state; (4) Toolbar Piano Roll hiển thị `(Belongs to: Track X)` và `Synth (Track X): Instrument`; (5) Đồng bộ channel assignment về `trackIndex % 16` (spec §4); (6) Toàn bộ `playNote` calls dùng `SonicPianoRoll.getTrackMidiChannel` thay for-loop channel compute.
|
||||
- **Các file ảnh hưởng:** `app/static/js/services/pianoRollTabService.js` (NEW), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
|
||||
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: multi-item piano roll switch item → scope cập nhật đúng. Parent track label xuất hiện trên toolbar.
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user