// ==================================================================
// ========== グローバル変数と設定 ==========
// ==================================================================
const noteScales = ["B6","A#6","A6","G#6","G6","F#6","F6","E6","D#6","D6","C#6","C6","B5","A#5","A5","G#5","G5","F#5","F5","E5","D#5","D5","C#5","C5","B4","A#4","A4","G#4","G4","F#4","F4","E4","D#4","D4","C#4","C4","B3","A#3","A3","G#3","G3","F#3","F3","E3","D#3","D3","C#3","C3","B2","A#2","A2","G#2","G2","F#2","F2","E2","D#2","D2","C#2","C2","B1","A#1","A1","G#1","G1","F#1","F1","E1","D#1","D1","C#1","C1"];
let numColumns = 16;
let pianoRoll1 = []; let pianoRoll2 = [];
let pianoRoll3 = []; let pianoRoll4 = [];
const instruments = {
"synth": (polyphony = 8) => new Tone.PolySynth(Tone.Synth, { maxPolyphony: polyphony }).toDestination(),
"piano": () => new Tone.Sampler({ urls: { "C3":"C3.mp3", "C4":"C4.mp3", "C5":"C5.mp3", "C6":"C6.mp3" }, baseUrl: "https://nbrosowsky.github.io/tonejs-instruments/samples/piano/" }).toDestination(),
"violin": () => new Tone.Sampler({ urls: { "C3":"C3.mp3", "C4":"C4.mp3", "C5":"C5.mp3", "C6":"C6.mp3" }, baseUrl: "https://nbrosowsky.github.io/tonejs-instruments/samples/violin/" }).toDestination(),
"trumpet": () => new Tone.Sampler({ urls: { "C4":"C4.mp3", "D5":"D5.mp3", "G3":"G3.mp3" }, baseUrl: "https://nbrosowsky.github.io/tonejs-instruments/samples/trumpet/" }).toDestination()
};
let instrument1 = instruments.synth(); let instrument2 = instruments.synth();
let instrument3 = instruments.piano(); let instrument4 = instruments.piano();
let part1, part2, part3, part4;
let currentOctave = 4; let activeKeyboardNote = null;
let currentTrack = "track1";
let canvas, ctx, timelineCanvas, timelineCtx;
const baseCellWidth = 50;
const baseCellHeight = 17;
let cellWidth = baseCellWidth;
let cellHeight = baseCellHeight;
const pitchHeaderWidth = 50;
let animationFrameId = null; let playbackCursor_16th = 0;
let isAuditionMode = false; let isDottedMode = false;
let currentNoteLengthBase = "8n";
// ▼▼▼ 範囲選択とノート操作のための新しい変数 ▼▼▼
let selectedNotes = []; // 選択されたノートオブジェクトを格納
let isDragging = false;
let isSelecting = false; // 範囲選択中かどうかのフラグ
let dragMode = null;
let selectedNote = null;
let dragStart_16th = 0;
let metronomeSound;
let metronomeEventId = null;
let dragStartNote = null;
let selectionRect = { startX: 0, startY: 0, currentX: 0, currentY: 0 }; // 範囲選択の矩形座標
let dragStartEvent = null;
let autoScrollInterval = null;
let isResizing = false;
const KEY_TO_NOTE = {
'a': 60, 'w': 61, 's': 62, 'e': 63, 'd': 64, 'f': 65, 't': 66,
'g': 67, 'y': 68, 'h': 69, 'u': 70, 'j': 71, 'k': 72
};
let pressedKeys = {}; // 現在押されているキーを管理
let noteBuffer = []; // 入力ノートを一時的にためる配列
let bufferTimeout = null; // タイマーID
// ==================================================================
// ========== 初期化処理 ==========
// ==================================================================
document.addEventListener("DOMContentLoaded", () => {
canvas = document.getElementById("pianoRollCanvas");
ctx = canvas.getContext("2d");
timelineCanvas = document.getElementById("timelineCanvas");
timelineCtx = timelineCanvas.getContext("2d");
metronomeSound = new Tone.MembraneSynth({ pitchDecay: 0.01, octaves: 6, oscillator: { type: "square" }, envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.1 }, volume: -10 }).toDestination();
console.log("Metronome synth created.");
setupMainControls();
setupNoteTools();
setupEventListeners();
drawMiniKeyboard();
document.getElementById("setMeasuresButton").addEventListener("click", setNumMeasures);
document.getElementById("showDataButton").addEventListener("click", toggleDataDisplay);
initializeMIDI(); // MIDI初期化
setInterval(pollMIDIDevices, 1000); // 1秒ごとにMIDI接続をチェック
resizeCanvas();
redrawAll();
});
// ==================================================================
// ========== UIセットアップ ==========
// ==================================================================
function setupMainControls() {
document.getElementById("playButton").addEventListener("click", playMusic);
document.getElementById("pauseButton").addEventListener("click", pauseMusic);
document.getElementById("stopButton").addEventListener("click", stopMusic);
document.getElementById("exportMidiButton").addEventListener("click", exportMidi);
const importMidiInput = document.getElementById("importMidiInput");
importMidiInput.addEventListener("change", (event) => {
importMidi(event.target.files[0]);
});
// ▼▼▼ ヘルプモーダルのロジックを修正 ▼▼▼
const helpModal = document.getElementById("help-modal");
const accordionContainer = document.getElementById("accordion-container");
let isHelpContentLoaded = false; // コンテンツが読み込まれたかどうかのフラグ
// --- ヘルプコンテンツを非同期で読み込み、アコーディオンを生成する関数 ---
async function loadHelpContent() {
if (isHelpContentLoaded) return; // 既に読み込み済みなら何もしない
try {
const response = await fetch('./help-contents.json'); // JSONファイルを読み込む
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// JSONデータからHTMLを生成
data.sections.forEach(section => {
const item = document.createElement('div');
item.className = 'accordion-item';
const header = document.createElement('button');
header.className = 'accordion-header';
header.textContent = section.title;
const content = document.createElement('div');
content.className = 'accordion-content';
// コンテンツの各行を<p>タグに変換して追加
section.content.forEach(line => {
const p = document.createElement('p');
p.innerHTML = line; // innerHTMLで <strong> などを解釈させる
content.appendChild(p);
});
item.appendChild(header);
item.appendChild(content);
accordionContainer.appendChild(item);
// アコーディオンのクリックイベントを設定
header.addEventListener('click', () => {
header.classList.toggle('active');
if (content.style.maxHeight) {
content.style.maxHeight = null;
content.style.padding = "0 18px";
} else {
content.style.padding = "18px";
content.style.maxHeight = content.scrollHeight + "px";
}
});
});
isHelpContentLoaded = true; // 読み込み完了フラグを立てる
} catch (error) {
console.error("Could not load help content:", error);
accordionContainer.innerHTML = "<p>ヘルプコンテンツの読み込みに失敗しました。</p>";
}
}
// --- 各ボタンのイベントリスナー ---
document.getElementById("helpButton").addEventListener("click", () => {
loadHelpContent(); // 開くときにコンテンツを読み込む
helpModal.classList.remove("hidden");
});
document.getElementById("close-modal-button").addEventListener("click", () => {
helpModal.classList.add("hidden");
});
helpModal.addEventListener("click", (event) => {
if (event.target === helpModal) {
helpModal.classList.add("hidden");
}
});
const trackSelector = document.getElementById("track-selector");
trackSelector.querySelectorAll('.tool-item').forEach(item => {
if (item.dataset.track === currentTrack) item.classList.add('selected');
item.addEventListener('click', () => {
trackSelector.querySelector('.selected').classList.remove('selected');
item.classList.add('selected');
currentTrack = item.dataset.track;
redrawAll();
updatePianoRollTextBox();
});
});
const inst1Select = document.getElementById("instrumentSelect1");
const inst2Select = document.getElementById("instrumentSelect2");
const inst3Select = document.getElementById("instrumentSelect3");
const inst4Select = document.getElementById("instrumentSelect4");
inst1Select.addEventListener('change', () => instrument1 = instruments[inst1Select.value]());
inst2Select.addEventListener('change', () => instrument2 = instruments[inst2Select.value]());
inst3Select.addEventListener('change', () => instrument3 = instruments[inst3Select.value]());
inst4Select.addEventListener('change', () => instrument4 = instruments[inst4Select.value]());
const metronomeSelector = document.getElementById("metronome-selector");
const metroItems = metronomeSelector.querySelectorAll('.tool-item');
// 初期状態を 'off' に設定
metronomeSelector.querySelector('[data-state="off"]').classList.add('selected');
metroItems.forEach(item => {
item.addEventListener('click', () => {
metroItems.forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
const state = item.dataset.state;
toggleMetronome(state);
});
});
}
function setupNoteTools() {
const noteLengthSelector = document.getElementById("note-length-selector");
noteLengthSelector.querySelectorAll('.tool-item[data-length]').forEach(tool => {
if (tool.dataset.length === currentNoteLengthBase) {
tool.classList.add('selected');
}
tool.addEventListener('click', () => {
noteLengthSelector.querySelector('.selected')?.classList.remove('selected');
tool.classList.add('selected');
currentNoteLengthBase = tool.dataset.length;
});
});
const dotButton = document.getElementById("dotButton");
dotButton.addEventListener('click', () => {
isDottedMode = !isDottedMode;
dotButton.classList.toggle('active', isDottedMode);
});
const auditionButton = document.getElementById("auditionButton");
auditionButton.addEventListener('click', () => {
isAuditionMode = !isAuditionMode;
auditionButton.classList.toggle('active', isAuditionMode);
});
const zoomXInput = document.getElementById('zoom-x');
const zoomYInput = document.getElementById('zoom-y');
const applyZoom = () => {
const zoomX = parseInt(zoomXInput.value, 10) / 100;
const zoomY = parseInt(zoomYInput.value, 10) / 100;
// 新しいセルサイズを計算
cellWidth = baseCellWidth * zoomX;
cellHeight = baseCellHeight * zoomY;
// Canvasのサイズを再計算して、全体を再描画
resizeCanvas();
redrawAll();
};
zoomXInput.addEventListener('change', applyZoom);
zoomYInput.addEventListener('change', applyZoom);
}
/**
* UIにミニキーボードを描画する
*/
function drawMiniKeyboard() {
const container = document.getElementById("mini-keyboard-container");
container.innerHTML = '';
const keyLayout = [
{ key: 'a', midiNote: 60, type: 'white', blackKey: { key: 'w', midiNote: 61 } },
{ key: 's', midiNote: 62, type: 'white', blackKey: { key: 'e', midiNote: 63 } },
{ key: 'd', midiNote: 64, type: 'white' },
{ key: 'f', midiNote: 65, type: 'white', blackKey: { key: 't', midiNote: 66 } },
{ key: 'g', midiNote: 67, type: 'white', blackKey: { key: 'y', midiNote: 68 } },
{ key: 'h', midiNote: 69, type: 'white', blackKey: { key: 'u', midiNote: 70 } },
{ key: 'j', midiNote: 71, type: 'white' },
{ key: 'k', midiNote: 72, type: 'white' },
];
/**
* キーが操作されたときに音を鳴らし、ノートを記録する共通関数
* @param {number} midiNote - 再生・記録するMIDIノート番号
* @param {HTMLElement} keyDiv - 操作されたキーのDOM要素
*/
const onKeyInteraction = (midiNote, keyDiv) => {
const noteName = getNoteName(midiNote + (currentOctave - 4) * 12);
const instrument = eval(`instrument${currentTrack.slice(-1)}`);
const noteDuration = getCurrentNoteLength();
// 音を鳴らす
instrument.triggerAttack(noteName, Tone.now());
// ノートを記録
recordNoteData({ scale: noteName, length: noteDuration });
// ▼▼▼ ハイライト処理 ▼▼▼
// 1. すぐに 'pressed' クラスを追加
keyDiv.classList.add('pressed');
// 2. 音の長さ分だけ待ってから、音を止め、クラスを削除
setTimeout(() => {
instrument.triggerRelease(noteName);
keyDiv.classList.remove('pressed');
}, Tone.Time(noteDuration).toMilliseconds());
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
};
keyLayout.forEach(keyInfo => {
if (keyInfo.type === 'white') {
const whiteKeyDiv = document.createElement('div');
whiteKeyDiv.className = 'mini-key white';
whiteKeyDiv.dataset.key = keyInfo.key;
whiteKeyDiv.textContent = keyInfo.key.toUpperCase();
whiteKeyDiv.addEventListener('mousedown', (e) => {
e.stopPropagation();
onKeyInteraction(keyInfo.midiNote, whiteKeyDiv); // ★ keyDivを渡す
});
if (keyInfo.blackKey) {
const blackKeyDiv = document.createElement('div');
blackKeyDiv.className = 'mini-key black';
blackKeyDiv.dataset.key = keyInfo.blackKey.key;
blackKeyDiv.textContent = keyInfo.blackKey.key.toUpperCase();
blackKeyDiv.addEventListener('mousedown', (e) => {
e.stopPropagation();
onKeyInteraction(keyInfo.blackKey.midiNote, blackKeyDiv); // ★ keyDivを渡す
});
whiteKeyDiv.appendChild(blackKeyDiv);
}
container.appendChild(whiteKeyDiv);
}
});
}
/**
* 現在選択されている音符の長さを計算して返す(付点を考慮)
* @returns {string} Tone.jsが解釈できる音長表記 (例: "4n", "8n.")
*/
function getCurrentNoteLength() {
// 付点モードがオンの場合
if (isDottedMode) {
// 選択されている基本の長さに "." を付けて返す
return `${currentNoteLengthBase}.`;
}
// 付点モードがオフの場合は、そのまま基本の長さを返す
return currentNoteLengthBase;
}
// ==================================================================
// ========== Canvas描画関連 ==========
// ==================================================================
function resizeCanvas() {
const newWidth = pitchHeaderWidth + (cellWidth * numColumns);
// ▼▼▼ Canvasの高さは、常に全音域を描画できる高さに固定する ▼▼▼
const newHeight = cellHeight * noteScales.length;
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
canvas.width = newWidth;
canvas.height = newHeight;
// タイムラインの幅は追従させ、高さは固定
timelineCanvas.width = newWidth;
timelineCanvas.height = 30;
}
function redrawAll() {
drawPianoRoll();
drawTimeline();
}
function drawPianoRoll() {
if (!ctx) return;
ctx.fillStyle = "#333";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
noteScales.forEach((scale, i) => {
const y = i * cellHeight;
if (scale.includes("#")) { ctx.fillStyle = "#383838"; } else { ctx.fillStyle = "#484848"; }
ctx.fillRect(0, y, canvas.width, cellHeight);
ctx.strokeStyle = "#555";
ctx.beginPath();
ctx.moveTo(pitchHeaderWidth, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
ctx.fillStyle = "white";
ctx.fillText(scale, pitchHeaderWidth / 2, y + cellHeight / 2);
});
ctx.strokeStyle = "#999";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pitchHeaderWidth, 0);
ctx.lineTo(pitchHeaderWidth, canvas.height);
ctx.stroke();
ctx.strokeStyle = "#4a4a4a";
ctx.lineWidth = 1;
for (let i = 0; i < numColumns * 2; i++) {
if (i % 2 !== 0) {
const x = pitchHeaderWidth + (i * (cellWidth / 2));
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
}
for (let i = 0; i <= numColumns; i++) {
const x = pitchHeaderWidth + i * cellWidth;
if (i % 8 === 0) { ctx.strokeStyle = "#999"; ctx.lineWidth = 2; }
else if (i % 4 === 0) { ctx.strokeStyle = "#666"; ctx.lineWidth = 1; }
else { ctx.strokeStyle = "#555"; ctx.lineWidth = 1; }
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
ctx.lineWidth = 1;
const inactiveColor = (hex) => `${hex}80`;
drawNotes(pianoRoll1, currentTrack === 'track1' ? "#4CAF50" : inactiveColor("#4CAF50"));
drawNotes(pianoRoll2, currentTrack === 'track2' ? "#3498DB" : inactiveColor("#3498DB"));
drawNotes(pianoRoll3, currentTrack === 'track3' ? "#FFC107" : inactiveColor("#FFC107"));
drawNotes(pianoRoll4, currentTrack === 'track4' ? "#E91E63" : inactiveColor("#E91E63"));
if (isSelecting) {
ctx.fillStyle = "rgba(52, 152, 219, 0.3)"; // 半透明の青
ctx.strokeStyle = "rgba(52, 152, 219, 0.8)";
ctx.lineWidth = 1;
const rect = getNormalizedSelectionRect();
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
}
}
function drawNotes(pianoRollData, color) {
pianoRollData.forEach(note => {
if (note.scale === "rest") return;
const startCol_8th = note.start_16th / 2;
const durationInCols_8th = note.length_16th / 2;
const rowIndex = noteScales.indexOf(note.scale);
if (rowIndex !== -1) {
const x = pitchHeaderWidth + startCol_8th * cellWidth;
const y = rowIndex * cellHeight;
const noteWidth = durationInCols_8th * cellWidth - 1;
ctx.fillStyle = color;
ctx.fillRect(x + 1, y + 1, noteWidth, cellHeight - 2);
// ▼▼▼ 選択されているノートにハイライトを追加 ▼▼▼
if (selectedNotes.includes(note)) {
ctx.strokeStyle = "white";
ctx.lineWidth = 2;
ctx.strokeRect(x + 1, y + 1, noteWidth, cellHeight - 2);
}
}
});
}
function drawTimeline() {
if (!timelineCtx) return;
const t_ctx = timelineCtx;
t_ctx.fillStyle = "#2c2c2c";
t_ctx.fillRect(0, 0, timelineCanvas.width, timelineCanvas.height);
t_ctx.font = "12px Arial";
t_ctx.textAlign = "left";
t_ctx.textBaseline = "middle";
t_ctx.fillStyle = "white";
const numMeasures = numColumns / 8;
for (let i = 0; i < numMeasures; i++) {
const measureX = pitchHeaderWidth + (i * 8) * cellWidth;
t_ctx.fillText(i + 1, measureX + 5, 15);
for (let j = 0; j < 8; j++) {
const beatX = measureX + j * cellWidth;
if (j === 0) { t_ctx.strokeStyle = "#999"; t_ctx.beginPath(); t_ctx.moveTo(beatX, 0); t_ctx.lineTo(beatX, 30); t_ctx.stroke(); }
else if (j % 2 === 0) { t_ctx.strokeStyle = "#666"; t_ctx.beginPath(); t_ctx.moveTo(beatX, 10); t_ctx.lineTo(beatX, 30); t_ctx.stroke(); }
else { t_ctx.strokeStyle = "#555"; t_ctx.beginPath(); t_ctx.moveTo(beatX, 20); t_ctx.lineTo(beatX, 30); t_ctx.stroke(); }
}
}
const endX = pitchHeaderWidth + numColumns * cellWidth;
t_ctx.strokeStyle = "#999";
t_ctx.beginPath();
t_ctx.moveTo(endX, 0);
t_ctx.lineTo(endX, 30);
t_ctx.stroke();
if (playbackCursor_16th >= 0) {
const cursorX = pitchHeaderWidth + (playbackCursor_16th / 2) * cellWidth;
timelineCtx.strokeStyle = "rgba(255, 0, 0, 0.8)";
timelineCtx.lineWidth = 2;
timelineCtx.beginPath();
timelineCtx.moveTo(cursorX, 0);
timelineCtx.lineTo(cursorX, 30);
timelineCtx.stroke();
timelineCtx.lineWidth = 1;
}
}
// ==================================================================
// ========== イベントハンドラ (オートスクロール最終修正版) ==========
// ==================================================================
function setupEventListeners() {
// マウスダウンイベントのみを、常に監視する
canvas.addEventListener('mousedown', handleMouseDown);
timelineCanvas.addEventListener('mousedown', handleTimelineClick);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
// リサイズハンドルのイベント
const resizeHandle = document.getElementById("resize-handle");
resizeHandle.addEventListener('mousedown', (event) => {
isResizing = true;
window.addEventListener('mousemove', handleResizeMove);
window.addEventListener('mouseup', handleResizeUp);
});
}
/**
* マウスイベントから座標を取得する。
* @param {MouseEvent} event - マウスイベントオブジェクト
* @param {boolean} relativeToContainer - trueの場合、コンテナの表示領域基準の座標を返す
* @returns {{x: number, y: number}} 座標オブジェクト
*/
function getMousePos(event, relativeToContainer = false) {
const container = document.getElementById("pianoRollContainer");
const rect = container.getBoundingClientRect();
let x = event.clientX - rect.left;
let y = event.clientY - rect.top;
if (!relativeToContainer) {
// Canvas内の絶対座標(スクロール量を加味)を返す
x += container.scrollLeft;
y += container.scrollTop;
}
// relativeToContainer = true なら、ビューポート(表示領域)基準の座標を返す
return { x, y };
}
/**
* ピアノロールでマウスボタンが押されたときの処理
* @param {MouseEvent} event
*/
function handleMouseDown(event) {
dragStartEvent = event;
const pos = getMousePos(event);
if (pos.x < pitchHeaderWidth) return;
const targetPianoRoll = eval(`pianoRoll${currentTrack.slice(-1)}`);
const clicked_16th_col = Math.floor((pos.x - pitchHeaderWidth) / (cellWidth / 2));
const scale = noteScales[Math.floor((pos.y - timelineCanvas.height) / cellHeight)];
const clickedNote = targetPianoRoll.find(note =>
note.scale === scale &&
note.start_16th <= clicked_16th_col &&
clicked_16th_col < note.start_16th + note.length_16th
);
dragStart_16th = clicked_16th_col;
isDragging = true;
if (clickedNote) {
selectedNote = clickedNote;
dragStartNote = { ...selectedNote };
const handleWidth = 8; // ハンドルの判定幅を少し広げる
const xInNote = pos.x - (pitchHeaderWidth + (selectedNote.start_16th / 2) * cellWidth);
const isRightEdge = xInNote > (selectedNote.length_16th / 2 * cellWidth) - handleWidth;
// ▼▼▼ ここからロジックを修正 ▼▼▼
if (selectedNotes.includes(clickedNote)) {
// --- 選択済みのノートの上でクリック ---
if (isRightEdge) {
// 右端なら -> グループリサイズモード
dragMode = 'resize-group-right';
} else {
// それ以外なら -> グループ移動モード
dragMode = 'move-group';
}
// 全選択ノートの元の状態を保存
selectedNotes.forEach(note => note.originalState = { ...note });
} else {
// --- 選択されていないノートの上でクリック ---
if (isRightEdge) {
dragMode = 'resize-right';
} else if (xInNote < handleWidth) {
dragMode = 'resize-left';
} else {
dragMode = 'move';
}
}
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
} else {
// --- 空の場所でクリック ---
isSelecting = true;
selectionRect.startX = pos.x;
selectionRect.startY = pos.y;
selectionRect.currentX = pos.x;
selectionRect.currentY = pos.y;
selectedNotes = [];
}
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
/**
* マウスが動いているときの処理
* @param {MouseEvent} event
*/
function handleMouseMove(event) {
const pos = getMousePos(event);
if (isSelecting) {
handleAutoScroll(event);
selectionRect.currentX = pos.x;
selectionRect.currentY = pos.y;
updateSelection();
} else if (isDragging && selectedNote) {
const current_16th_col = Math.max(0, Math.floor((pos.x - pitchHeaderWidth) / (cellWidth / 2)));
const currentRow = Math.floor((pos.y - timelineCanvas.height) / cellHeight);
const diff_16th = current_16th_col - dragStart_16th;
if (dragMode === 'resize-group-right') {
// グループリサイズ
selectedNotes.forEach(note => {
const newLength = note.originalState.length_16th + diff_16th;
note.length_16th = Math.max(1, newLength); // 最小長は1
});
}if (dragMode === 'move-group') {
const startPos = getMousePos(dragStartEvent);
const startRow = Math.floor((startPos.y - timelineCanvas.height) / cellHeight);
const diff_row = currentRow - startRow;
selectedNotes.forEach(note => {
note.start_16th = Math.max(0, note.originalState.start_16th + diff_16th);
const originalRow = noteScales.indexOf(note.originalState.scale);
const newRow = originalRow + diff_row;
if (newRow >= 0 && newRow < noteScales.length) {
note.scale = noteScales[newRow];
}
});
} else if (dragMode === 'move') {
selectedNote.start_16th = Math.max(0, dragStartNote.start_16th + diff_16th);
if (currentRow >= 0 && currentRow < noteScales.length) {
selectedNote.scale = noteScales[currentRow];
}
} else if (dragMode === 'resize-right') {
selectedNote.length_16th = Math.max(1, dragStartNote.length_16th + diff_16th);
} else if (dragMode === 'resize-left') {
const newStart = dragStartNote.start_16th + diff_16th;
if (newStart < dragStartNote.start_16th + dragStartNote.length_16th) {
const finalNewStart = Math.max(0, newStart);
const newLength = dragStartNote.length_16th - (finalNewStart - dragStartNote.start_16th);
if (newLength >= 1) {
selectedNote.start_16th = finalNewStart;
selectedNote.length_16th = newLength;
}
}
}
}
redrawAll();
}
/**
* マウスボタンが離されたときの処理
* @param {MouseEvent} event
*/
function handleMouseUp(event) {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
if (autoScrollInterval) {
clearInterval(autoScrollInterval);
autoScrollInterval = null;
}
if (!isDragging && !isSelecting) return;
const targetPianoRoll = eval(`pianoRoll${currentTrack.slice(-1)}`);
const pos = getMousePos(event);
const up_16th_col = Math.floor((pos.x - pitchHeaderWidth) / (cellWidth / 2));
if (isSelecting) {
updateSelection();
if (selectionRect.startX === selectionRect.currentX && selectionRect.startY === selectionRect.currentY) {
const scale = noteScales[Math.floor((pos.y - timelineCanvas.height) / cellHeight)];
if(scale) {
const currentLength = getCurrentNoteLength();
if(currentLength !== 'rest') {
targetPianoRoll.push({
start_16th: up_16th_col,
scale: scale,
length_16th: Tone.Time(currentLength).toTicks() / Tone.Time("16n").toTicks()
});
}
}
selectedNotes = [];
}
} else if (isDragging && selectedNote) {
const isShortClick = dragStart_16th === up_16th_col && dragStartNote.scale === noteScales[Math.floor((pos.y - timelineCanvas.height)/cellHeight)];
if (isShortClick && dragMode !== 'move-group') {
const noteIndexToDelete = targetPianoRoll.indexOf(selectedNote);
if (noteIndexToDelete > -1) {
targetPianoRoll.splice(noteIndexToDelete, 1);
}
} else {
if (dragMode === 'move-group') {
selectedNotes.forEach(note => delete note.originalState);
}
}if (dragMode === 'move-group' || dragMode === 'resize-group-right') {
selectedNotes.forEach(note => delete note.originalState);
}
}
targetPianoRoll.sort((a, b) => a.start_16th - b.start_16th);
isDragging = false;
isSelecting = false;
dragMode = null;
selectedNote = null;
dragStartNote = null;
redrawAll();
updatePianoRollTextBox();
}
/**
* 範囲選択中のオートスクロールを処理する
* @param {MouseEvent} event
*/
function handleAutoScroll(event) {
const container = document.getElementById("pianoRollContainer");
const pos = getMousePos(event, true); // ビューポート基準の座標
const scrollMargin = 40;
const scrollSpeed = 10;
let dx = 0, dy = 0;
if (pos.x < scrollMargin) dx = -scrollSpeed;
else if (pos.x > container.clientWidth - scrollMargin) dx = scrollSpeed;
if (pos.y < scrollMargin) dy = -scrollSpeed;
else if (pos.y > container.clientHeight - scrollMargin) dy = scrollSpeed;
if (dx !== 0 || dy !== 0) {
if (!autoScrollInterval) {
autoScrollInterval = setInterval(() => {
container.scrollBy(dx, dy);
selectionRect.currentX += dx;
selectionRect.currentY += dy;
updateSelection();
redrawAll();
}, 16);
}
} else {
if (autoScrollInterval) {
clearInterval(autoScrollInterval);
autoScrollInterval = null;
}
}
}
/**
* タイムラインがクリックされたときの処理
* @param {MouseEvent} event
*/
function handleTimelineClick(event) {
if (Tone.Transport.state === 'started') return;
const rect = timelineCanvas.getBoundingClientRect();
const x = event.clientX - rect.left;
let clicked_16th_col;
if (x < pitchHeaderWidth) {
clicked_16th_col = 0;
} else {
clicked_16th_col = Math.round((x - pitchHeaderWidth) / (cellWidth / 2));
}
const max_16th_cols = numColumns * 2;
playbackCursor_16th = Math.min(clicked_16th_col, max_16th_cols);
Tone.Transport.seconds = playbackCursor_16th * Tone.Time("16n").toSeconds();
redrawAll();
}
/**
* 小節数設定ボタンが押されたときの処理
*/
function setNumMeasures() {
const measures = parseInt(document.getElementById("numMeasuresInput").value, 10);
if (!isNaN(measures) && measures > 0) {
numColumns = measures * 8;
resizeCanvas();
redrawAll();
} else {
alert("小節数は1以上の正の整数を入力してください。");
}
}
/**
* キーボードショートカットを処理する
* @param {KeyboardEvent} event
*/
function handleKeyDown(event) {
if (event.target.tagName === 'TEXTAREA' || event.target.tagName === 'INPUT') {
return;
}
const key = event.key.toLowerCase();
if (event.ctrlKey || event.metaKey) {
switch(key) {
case "c": event.preventDefault(); copySelectedNotes(); return;
case "v": event.preventDefault(); pasteNotes(); return;
}
}
if (KEY_TO_NOTE.hasOwnProperty(key)) {
event.preventDefault();
if (!pressedKeys[key]) {
pressedKeys[key] = true;
document.querySelector(`.mini-key[data-key="${key}"]`)?.classList.add('pressed');
const midiNote = KEY_TO_NOTE[key] + (currentOctave - 4) * 12;
const noteName = getNoteName(midiNote);
const instrument = eval(`instrument${currentTrack.slice(-1)}`);
instrument.triggerAttack(noteName, Tone.now());
recordNoteData({ scale: noteName, length: getCurrentNoteLength() });
}
return;
}
let needsRedraw = true;
switch(event.key) {
case "l": decreaseOctave(); break;
case ";": case "+": increaseOctave(); break;
case "ArrowUp": case "ArrowDown": case "ArrowLeft": case "ArrowRight":
if (selectedNotes.length === 0) { needsRedraw = false; break; }
event.preventDefault();
moveSelectedNotes(event.key);
break;
case "Delete":
case "Backspace":
if (selectedNotes.length === 0) { needsRedraw = false; break; }
event.preventDefault();
deleteSelectedNotes(); // ★ ヘルパー関数を呼び出す
break;
default:
needsRedraw = false;
}
if (needsRedraw) {
redrawAll();
updatePianoRollTextBox();
}
}
/**
* 選択されたノートを矢印キーで移動させるヘルパー関数
* @param {string} direction
*/
function moveSelectedNotes(direction) {
switch(direction) {
case "ArrowUp":
selectedNotes.forEach(note => {
const currentIndex = noteScales.indexOf(note.scale);
if (currentIndex > 0) note.scale = noteScales[currentIndex - 1];
});
break;
case "ArrowDown":
selectedNotes.forEach(note => {
const currentIndex = noteScales.indexOf(note.scale);
if (currentIndex < noteScales.length - 1) note.scale = noteScales[currentIndex + 1];
});
break;
case "ArrowLeft":
if (selectedNotes.every(note => note.start_16th > 0)) {
selectedNotes.forEach(note => note.start_16th -= 1);
}
break;
case "ArrowRight":
const maxCols = numColumns * 2;
if (selectedNotes.every(note => (note.start_16th + note.length_16th) < maxCols)){
selectedNotes.forEach(note => note.start_16th += 1);
}
break;
}
}
/**
* 選択されたノートを削除するヘルパー関数
*/
function deleteSelectedNotes() {
const targetPianoRoll = eval(`pianoRoll${currentTrack.slice(-1)}`);
selectedNotes.forEach(note => {
const index = targetPianoRoll.indexOf(note);
if (index > -1) {
targetPianoRoll.splice(index, 1);
}
});
selectedNotes = []; // 削除後は選択を解除
}
/**
* キーが離されたときの処理
* @param {KeyboardEvent} event
*/
function handleKeyUp(event) {
const key = event.key.toLowerCase();
if (KEY_TO_NOTE[key] && pressedKeys[key]) {
event.preventDefault();
pressedKeys[key] = false;
document.querySelector(`.mini-key[data-key="${key}"]`)?.classList.remove('pressed');
const midiNote = KEY_TO_NOTE[key] + (currentOctave - 4) * 12;
const noteName = getNoteName(midiNote);
instrument1.triggerRelease(noteName);
instrument2.triggerRelease(noteName);
instrument3.triggerRelease(noteName);
instrument4.triggerRelease(noteName);
}
}
/**
* ピアノロールのリサイズハンドルがドラッグされたときの処理
* @param {MouseEvent} event
*/
function handleResizeMove(event) {
if (!isResizing) return;
const pianoRollContainer = document.getElementById("pianoRollContainer");
const containerTop = pianoRollContainer.getBoundingClientRect().top;
const newHeight = event.clientY - containerTop;
if (newHeight > 100 && newHeight < window.innerHeight * 0.9) {
pianoRollContainer.style.height = `${newHeight}px`;
}
}
/**
* ピアノロールのリサイズが終了したときの処理
*/
function handleResizeUp() {
if (!isResizing) return;
isResizing = false;
window.removeEventListener('mousemove', handleResizeMove);
window.removeEventListener('mouseup', handleResizeUp);
redrawAll();
}
// ==================================================================
// ========== MIDI & スクリーンキーボード入力 ==========
// ==================================================================
let midiAccess = null;
/**
* MIDIデバイスへのアクセスを要求する初期化関数
*/
function initializeMIDI() {
if (navigator.requestMIDIAccess) {
navigator.requestMIDIAccess({ sysex: false })
.then(
(access) => {
console.log("MIDI Access successful. Starting connection polling...");
midiAccess = access; // 取得したアクセスオブジェクトをグローバル変数に保存
},
(error) => {
console.error("Failed to get MIDI access.", error);
document.getElementById("midistatus").innerText = "MIDI: 許可されていません";
}
);
} else {
document.getElementById("midistatus").innerText = "MIDI: 非対応ブラウザ";
}
}
/**
* MIDIデバイスの接続状態を定期的にチェックし、UIとイベントハンドラを更新する
*/
function pollMIDIDevices() {
if (!midiAccess) return;
let physicalDeviceFound = false;
midiAccess.inputs.forEach(input => {
const deviceName = input.name.toLowerCase();
if (!deviceName.includes("through") && !deviceName.includes("microsoft")) {
physicalDeviceFound = true;
}
});
const statusElement = document.getElementById("midistatus");
const isCurrentlyDisplayedAsConnected = statusElement.innerText === "MIDI: 接続済み";
if (physicalDeviceFound && !isCurrentlyDisplayedAsConnected) {
console.log("MIDI device connected. Updating status and attaching listeners.");
statusElement.innerText = "MIDI: 接続済み";
midiAccess.inputs.forEach(input => {
console.log(`Attaching MIDI listener to: ${input.name}`);
input.onmidimessage = handleMIDIMessage;
});
} else if (!physicalDeviceFound && isCurrentlyDisplayedAsConnected) {
console.log("MIDI device disconnected. Updating status.");
statusElement.innerText = "MIDI: 未接続";
}
}
/**
* MIDIメッセージを受信したときの処理
* @param {MIDIMessageEvent} event
*/
function handleMIDIMessage(event) {
const [status, note, velocity] = event.data;
const noteName = getNoteName(note);
const messageType = status & 0xf0;
if (messageType === 144 && velocity > 0) { // ノートオン
const instrument = eval(`instrument${currentTrack.slice(-1)}`);
instrument.triggerAttack(noteName, Tone.now(), velocity / 127);
recordNoteData({ scale: noteName, length: getCurrentNoteLength() });
} else if (messageType === 128 || (messageType === 144 && velocity === 0)) { // ノートオフ
instrument1.triggerRelease(noteName);
instrument2.triggerRelease(noteName);
instrument3.triggerRelease(noteName);
instrument4.triggerRelease(noteName);
}
}
function getNoteName(noteNumber) {
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(noteNumber / 12) - 1;
return `${notes[noteNumber % 12]}${octave}`;
}
/**
* MIDIやスクリーンキーボードからのノート入力を記録する
* 短時間に連続して入力されたノートは「和音」とみなし、同じ開始時間で記録する
* @param {object} noteObject - {scale: string, length: string}
*/
function recordNoteData(noteObject) {
// 試奏モードがオンの場合は、ピアノロールに記録せずに関数を終了する
if (isAuditionMode) {
return;
}
// 1. 既存のタイマーがあればクリアする
// (前のノート入力から50ms以内に次のノートが来たら、タイマーをリセットし、和音の構成音とみなす)
clearTimeout(bufferTimeout);
// 2. 新しいノートを一時的なバッファ配列に追加
noteBuffer.push(noteObject);
// 3. 50ミリ秒後に、バッファにたまったノートをまとめて記録するタイマーを設定
bufferTimeout = setTimeout(() => {
// タイマーが発火した時点でバッファが空なら何もしない
if (noteBuffer.length === 0) return;
// 現在の編集トラックを取得
const targetPianoRoll = eval(`pianoRoll${currentTrack.slice(-1)}`);
// バッファ内の全ノートの開始位置は、現在の再生カーソル位置に統一
const start_16th = playbackCursor_16th;
// カーソルを進めるために、和音の中で最も長い音符の長さを記録する変数
let maxNoteLength_16th = 0;
// バッファにたまったすべてのノートを処理
noteBuffer.forEach(note => {
// 音符の長さを「16分音符何個分か」に変換
const noteLength_16th = Tone.Time(note.length).toTicks() / Tone.Time("16n").toTicks();
// 和音の中で一番長いノートの長さを更新
if (noteLength_16th > maxNoteLength_16th) {
maxNoteLength_16th = noteLength_16th;
}
// すべてのノートを同じ開始時間でピアノロール配列に追加
targetPianoRoll.push({
start_16th: start_16th,
scale: note.scale,
length_16th: noteLength_16th
});
});
// カーソルを、和音の中で一番長いノートの分だけ進める
playbackCursor_16th += maxNoteLength_16th;
// Tone.Transportの再生時間も同期させる
Tone.Transport.seconds = playbackCursor_16th * Tone.Time("16n").toSeconds();
// 最後に、次の入力に備えてバッファをクリア
noteBuffer = [];
// ピアノロール配列を時間順にソートし、画面を再描画
targetPianoRoll.sort((a, b) => a.start_16th - b.start_16th);
focusOnCursor();
redrawAll();
updatePianoRollTextBox();
}, 50); // 50ミリ秒の猶予時間。この値で和音と判定する時間間隔を調整できる
}
const adjustOctave = (scale) => {
const note = scale.slice(0, -1);
const octave = parseInt(scale.slice(-1));
return `${note}${octave + currentOctave - 4}`;
};
const stop = () => {
if (activeKeyboardNote) {
const instrument = eval(`instrument${currentTrack.slice(-1)}`);
instrument.triggerRelease(activeKeyboardNote);
activeKeyboardNote = null;
}
};
// ==================================================================
// ========== 再生エンジン ==========
// ==================================================================
/**
* メトロノームのオン/オフと間隔を制御する
* @param {string} state - "off", "4n", "8n"
*/
function toggleMetronome(state) {
if (metronomeEventId !== null) {
Tone.Transport.clear(metronomeEventId);
metronomeEventId = null;
}
if (state === "off") {
if (Tone.Transport.state === 'started') {
const currentTime = Tone.Transport.seconds;
stopMusic();
playMusic();
Tone.Transport.seconds = currentTime;
}
return;
}
metronomeEventId = Tone.Transport.scheduleRepeat((time) => {
// 現在の再生位置を拍数で取得
const ticks = Tone.Transport.ticks;
const ppq = Tone.Transport.PPQ; // 4分音符あたりのTick数
const measureTicks = ppq * 4; // 1小節あたりのTick数
// ▼▼▼ 判定ロジックを修正 ▼▼▼
// Tickのズレを許容する許容範囲
const tolerance = 10;
// 小節の1拍目か?
const isFirstBeat = (ticks % measureTicks) < tolerance;
// 4分音符のタイミングか?
const isDownBeat = (ticks % ppq) < tolerance;
if (isFirstBeat) {
// 小節の頭: 高い音 & 大きい音量
metronomeSound.triggerAttackRelease("G4", "16n", time, 1.0);
} else if (isDownBeat) {
// 4分音符の拍: 中くらいの音 & 通常の音量
metronomeSound.triggerAttackRelease("C4", "16n", time, 0.7);
} else {
// 8分音符の裏拍 (8nモードの時のみ呼ばれる)
metronomeSound.triggerAttackRelease("C4", "16n", time, 0.4);
}
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
}, state, 0);
if (Tone.Transport.state === 'started') {
const currentTime = Tone.Transport.seconds;
stopMusic();
playMusic();
Tone.Transport.seconds = currentTime;
}
}
/**
* 音楽を再生する(メトロノーム単独再生対応)
*/
// ==================================================================
// ========== 再生エンジン (ライブアップデート対応版) ==========
// ==================================================================
async function playMusic() {
await Tone.start();
Tone.Transport.bpm.value = document.getElementById("tempo").value;
// 一時停止からの再開
if (Tone.Transport.state === 'paused') {
Tone.Transport.start();
startDrawingLoop();
return;
}
// 停止状態から新規に再生する場合
if (Tone.Transport.state !== 'started') {
await Tone.loaded();
// ▼▼▼ ループ開始イベントのスケジュールをここに追加 ▼▼▼
Tone.Transport.scheduleRepeat(time => {
// ループの開始地点で呼ばれる
// ここで再生スケジュールを再構築する
Tone.Draw.schedule(() => {
console.log("Loop start: Rebuilding playback schedule.");
setupPlayback(); // 最新のノート情報でパートを再作成
}, time);
}, "1m", 0); // 1小節(1m)ごとにチェック(曲の長さに応じて調整可能)
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
const totalDuration = numColumns * Tone.Time("8n").toSeconds();
Tone.Transport.loop = true;
Tone.Transport.loopStart = 0;
Tone.Transport.loopEnd = totalDuration > 0 ? totalDuration : Tone.Time("1m").toSeconds();
// 初回再生の準備
setupPlayback();
Tone.Transport.start();
startDrawingLoop();
}
}
function pauseMusic() {
Tone.Transport.pause();
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
}
function stopMusic() {
Tone.Transport.stop();
playbackCursor_16th = 0;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
// ▼▼▼ 停止時にスケジュールを全クリア ▼▼▼
Tone.Transport.cancel(0);
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
redrawAll();
}
function setupPlayback() {
// 既存のパートを破棄するだけで、Transportのスケジュールはクリアしない
if (part1) part1.dispose(); if (part2) part2.dispose();
if (part3) part3.dispose(); if (part4) part4.dispose();
const createPart = (pianoRoll, instrument) => {
const partData = convertToPartData(pianoRoll);
if (partData.length === 0) return null;
// パートを作成し、即座に開始させる
return new Tone.Part((time, value) => {
instrument.triggerAttackRelease(value.note, value.duration, time);
}, partData).start(0);
};
part1 = createPart(pianoRoll1, instrument1);
part2 = createPart(pianoRoll2, instrument2);
part3 = createPart(pianoRoll3, instrument3);
part4 = createPart(pianoRoll4, instrument4);
}
function startDrawingLoop() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
function loop() {
if (Tone.Transport.state !== 'started') {
animationFrameId = null;
return;
}
const sixteenthSecs = Tone.Time("16n").toSeconds();
playbackCursor_16th = Tone.Transport.seconds / sixteenthSecs;
redrawAll();
animationFrameId = requestAnimationFrame(loop);
}
animationFrameId = requestAnimationFrame(loop);
}
function resetMusic() {
stopMusic();
pianoRoll1 = []; pianoRoll2 = [];
pianoRoll3 = []; pianoRoll4 = [];
redrawAll();
updatePianoRollTextBox();
document.getElementById("pianoRoll").value = "";
document.getElementById("data-display-container").classList.add("hidden");
}
// ==================================================================
// ========== ユーティリティ & 残りのUI操作 ==========
// ==================================================================
/**
* 範囲選択矩形の正規化された座標(x, y, w, h)を返す
*/
function getNormalizedSelectionRect() {
const x = Math.min(selectionRect.startX, selectionRect.currentX);
const y = Math.min(selectionRect.startY, selectionRect.currentY);
const w = Math.abs(selectionRect.startX - selectionRect.currentX);
const h = Math.abs(selectionRect.startY - selectionRect.currentY);
return { x, y, w, h };
}
/**
* 範囲選択矩形内のノートを計算し、selectedNotes配列を更新する
*/
function updateSelection() {
const targetPianoRoll = eval(`pianoRoll${currentTrack.slice(-1)}`);
const selRect = getNormalizedSelectionRect();
selectedNotes = [];
targetPianoRoll.forEach(note => {
if (note.scale === 'rest') return;
const noteX = pitchHeaderWidth + (note.start_16th / 2) * cellWidth;
const noteY = noteScales.indexOf(note.scale) * cellHeight;
const noteW = (note.length_16th / 2) * cellWidth;
const noteH = cellHeight;
// AABB交差判定(矩形が重なっているか)
if (noteX < selRect.x + selRect.w && noteX + noteW > selRect.x &&
noteY < selRect.y + selRect.h && noteY + noteH > selRect.y) {
selectedNotes.push(note);
}
});
}
/**
* 現在選択されているノートをクリップボードにコピーする
*/
function copySelectedNotes() {
if (selectedNotes.length === 0) return;
const firstNoteTime = Math.min(...selectedNotes.map(n => n.start_16th));
clipboard = selectedNotes.map(note => {
return {
start_16th: note.start_16th - firstNoteTime,
scale: note.scale,
length_16th: note.length_16th
};
});
console.log("Notes copied to clipboard:", clipboard);
}
function convertToPartData(pianoRoll) {
const sixteenthSecs = Tone.Time("16n").toSeconds();
return pianoRoll.map(note => {
if (note.scale === 'rest') return null;
return {
time: note.start_16th * sixteenthSecs,
note: note.scale,
duration: note.length_16th * sixteenthSecs
};
}).filter(Boolean);
}
function updatePianoRollTextBox() {
const targetPianoRoll = eval(`pianoRoll${currentTrack.slice(-1)}`);
document.getElementById("pianoRoll").value = targetPianoRoll.map(n =>
`start:${n.start_16th} scale:${n.scale} len:${n.length_16th}`
).join('\n');
}
function editNote() {
const textData = document.getElementById("pianoRoll").value.trim();
const newPianoRoll = textData.split("\n").map(line => {
try {
const startMatch = line.match(/start:(\d+)/);
const scaleMatch = line.match(/scale:(\S+)/);
const lenMatch = line.match(/len:(\d+)/);
if (startMatch && scaleMatch && lenMatch) {
return {
start_16th: parseInt(startMatch[1]),
scale: scaleMatch[1],
length_16th: parseInt(lenMatch[1])
};
} return null;
} catch (e) { return null; }
}).filter(Boolean);
if (textData === "") {
eval(`pianoRoll${currentTrack.slice(-1)} = []`);
} else {
eval(`pianoRoll${currentTrack.slice(-1)} = newPianoRoll`);
}
redrawAll();
}
function toggleDataDisplay() {
const container = document.getElementById("data-display-container");
container.classList.toggle("hidden");
if (!container.classList.contains("hidden")) {
updatePianoRollTextBox();
}
}
const toggleKeyboard = () => document.getElementById("keyboard").classList.toggle("hidden");
const increaseOctave = () => {
// ▼▼▼ 最大値を 7 -> 6 に変更 ▼▼▼
if (currentOctave < 6) {
document.getElementById("currentOctave").textContent = ++currentOctave;
}
};
const decreaseOctave = () => { if (currentOctave > 1) document.getElementById("currentOctave").textContent = --currentOctave };
/**
* 現在選択されているノートをクリップボードにコピーする
*/
function copySelectedNotes() {
if (selectedNotes.length === 0) return;
// 選択範囲の最初のノートの開始時間を基準点(0)とする
const firstNoteTime = Math.min(...selectedNotes.map(n => n.start_16th));
clipboard = selectedNotes.map(note => {
return {
// 基準点からの相対的な開始時間で保存
start_16th: note.start_16th - firstNoteTime,
scale: note.scale,
length_16th: note.length_16th
};
});
console.log("Notes copied to clipboard:", clipboard); // デバッグ用
}
/**
* クリップボードのノートを現在の再生カーソル位置に貼り付ける
*/
function pasteNotes() {
if (clipboard.length === 0) {
console.log("Clipboard is empty."); // デバッグ用
return;
}
const targetPianoRoll = eval(`pianoRoll${currentTrack.slice(-1)}`);
const pasteStartTime = playbackCursor_16th;
const newNotes = clipboard.map(note => {
return {
start_16th: pasteStartTime + note.start_16th,
scale: note.scale,
length_16th: note.length_16th
};
});
// ▼▼▼ 重なりチェックを削除 ▼▼▼
/*
const isOverlapping = newNotes.some(newNote => ... );
if (isOverlapping) {
return;
}
*/
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
targetPianoRoll.push(...newNotes);
targetPianoRoll.sort((a, b) => a.start_16th - b.start_16th);
// 貼り付け後、新しく貼り付けたノートを選択状態にする
selectedNotes = newNotes;
// カーソルを貼り付けたノート群の末尾に移動
const lastNoteEndTime = Math.max(...newNotes.map(n => n.start_16th + n.length_16th));
playbackCursor_16th = lastNoteEndTime;
Tone.Transport.seconds = playbackCursor_16th * Tone.Time("16n").toSeconds();
redrawAll();
updatePianoRollTextBox();
console.log("Notes pasted.");
}
/**
* 現在の再生カーソル位置が画面の中心に来るように、ピアノロールを自動スクロールする
*/
function focusOnCursor() {
const container = document.getElementById("pianoRollContainer");
// --- 横方向のスクロール ---
// カーソルのX座標(ピクセル単位)を計算
const cursorX = pitchHeaderWidth + (playbackCursor_16th / 2) * cellWidth;
// 画面中央より少し左(4分の1の位置)に来るように目標を設定
const targetScrollLeft = cursorX - (container.clientWidth / 4);
// `scrollLeft` は、コンテナの現在のスクロール位置
const currentScrollLeft = container.scrollLeft;
const viewWidth = container.clientWidth;
// カーソルが画面外に出た場合のみスクロールを実行
if (cursorX < currentScrollLeft || cursorX > currentScrollLeft + viewWidth) {
container.scrollLeft = targetScrollLeft;
}
// --- 縦方向のスクロール ---
// 最後に入力されたノートを探す(カーソル位置の直前にあるノート)
const targetPianoRoll = eval(`pianoRoll${currentTrack.slice(-1)}`);
// playbackCursor_16thはノート入力後に進んでいるため、その前の位置のノートを探す
const lastNote = targetPianoRoll.filter(n => n.start_16th < playbackCursor_16th).pop();
if (lastNote && lastNote.scale) {
const noteRow = noteScales.indexOf(lastNote.scale);
if (noteRow !== -1) {
// ノートのY座標(ピクセル単位)を計算
const noteY = noteRow * cellHeight;
// 画面の中央に来るように目標のスクロール位置を計算
const targetScrollTop = noteY - (container.clientHeight / 2);
const currentScrollTop = container.scrollTop;
const viewHeight = container.clientHeight;
// ノートが画面外に出た場合のみスクロールを実行
if (noteY < currentScrollTop || noteY > currentScrollTop + viewHeight - cellHeight) {
container.scrollTop = targetScrollTop;
}
}
}
}
// ==================================================================
// ========== MIDIファイル入出力 ==========
// ==================================================================
/**
* 現在のプロジェクトをMIDIファイルとして書き出す
*/
async function exportMidi() {
// ▼▼▼ ここを修正 ▼▼▼
// 1. 新しいMidiオブジェクトを作成
const midi = new Midi(); // "Midi.Midi" から "Midi" に変更
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
// 2. ヘッダー情報を設定
midi.header.setTempo(parseInt(document.getElementById("tempo").value, 10));
midi.header.timeSignatures.push({ ticks: 0, timeSignature: [4, 4] });
// 3. 各トラックのノートをMIDIトラックに追加
const allTracks = [
{ data: pianoRoll1, name: "Track 1", instrument: instrument1 },
{ data: pianoRoll2, name: "Track 2", instrument: instrument2 },
{ data: pianoRoll3, name: "Track 3", instrument: instrument3 },
{ data: pianoRoll4, name: "Track 4", instrument: instrument4 }
];
allTracks.forEach((trackInfo, index) => {
if (trackInfo.data.length === 0) return; // 空のトラックはスキップ
const track = midi.addTrack();
track.name = trackInfo.name;
// 楽器の種類を設定 (簡略版)
if(trackInfo.instrument.name === "Sampler") track.instrument.number = 0; // 0: Acoustic Grand Piano
const sixteenthSecs = Tone.Time("16n").toSeconds();
trackInfo.data.forEach(note => {
if (note.scale !== 'rest') {
track.addNote({
name: note.scale,
time: note.start_16th * sixteenthSecs,
duration: note.length_16th * sixteenthSecs,
velocity: 0.8 // ベロシティは固定
});
}
});
});
// 4. MIDIデータをBlobとして生成
const blob = new Blob([midi.toArray()], { type: "audio/midi" });
// 5. ダウンロード用のリンクを作成してクリック
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "my_song.mid";
a.click();
URL.revokeObjectURL(a.href);
}
/**
* ユーザーが選択したMIDIファイルを読み込む
* @param {File} file - 読み込むファイルオブジェクト
*/
async function importMidi(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
// ▼▼▼ ここを修正 ▼▼▼
const midi = new Midi(e.target.result); // "Midi.Midi" から "Midi" に変更
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲
resetMusic();
if (midi.header.tempos.length > 0) {
document.getElementById("tempo").value = midi.header.tempos[0].bpm;
Tone.Transport.bpm.value = midi.header.tempos[0].bpm;
}
const allPianoRolls = [pianoRoll1, pianoRoll2, pianoRoll3, pianoRoll4];
midi.tracks.forEach((track, index) => {
if (index >= allPianoRolls.length) return; // 4トラックまで対応
const targetPianoRoll = allPianoRolls[index];
track.notes.forEach(note => {
// MIDIデータを16分音符単位に変換
const sixteenthTicks = Tone.Time("16n").toTicks();
targetPianoRoll.push({
start_16th: Math.round(Tone.Time(note.time).toTicks() / sixteenthTicks),
scale: note.name,
length_16th: Math.round(Tone.Time(note.duration).toTicks() / sixteenthTicks)
});
});
});
console.log("MIDI file imported successfully.");
redrawAll();
updatePianoRollTextBox();
} catch (error) {
console.error("Error parsing MIDI file:", error);
alert("MIDIファイルの読み込みに失敗しました。");
}
};
reader.readAsArrayBuffer(file);
}