MIDI曲を楽に弾ける「Easy Midi Player」
【更新履歴】
2026/1/30 ・バージョン1.0公開。
2026/1/30 ・バージョン1.1公開。(再生エリアの移動を追加)
2026/1/30 ・バージョン1.2公開。(音伸ばしと再生速度調整を追加)
2026/2/1 ・バージョン1.3公開。(ビブラート、強弱調節などを追加)
《ゲームの概要》
・いわゆる「音ゲー」のワンクリック版みたいなものです。
《操作説明》
・まず、「ファイルを選択」ボタンを押して、
演奏するMIDIファイルを選びます。
・「最初から再生」ボタンを押すと、演奏開始です。
・マウスを移動させると、再生エリアが上下に動きますから、
上から落ちてくるノート(発音ブロック)を合わせて、
左クリックをすると、
再生エリアと重なっているノートの音だけが鳴ります。
・ノートにマウスポインタを合わせて右クリックすると、
そのノートの音だけが鳴ります。
・マウスボタンは、押しっぱなしにすると、
その間だけスクロールが止まって、
音が鳴りっぱなしになります。
・ダウンロードされる方はこちら。↓
・著作権フリーのMIDIファイルは
こちらのwebサイトからダウンロードできます。↓
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Easy MIDI Player 1.3</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
<script src="https://unpkg.com/@tonejs/midi"></script>
<style>
body { margin: 0; background: #111; color: white; font-family: sans-serif; overflow: hidden; user-select: none; }
canvas {
display: block;
margin: 0 auto;
background: #222;
border-left: 1px solid #444;
border-right: 1px solid #444;
cursor: crosshair;
}
canvas:active { cursor: grabbing; }
#ui {
position: absolute; top: 0; left: 0; width: 100%;
background: rgba(0, 0, 0, 0.9); padding: 8px; box-sizing: border-box;
border-bottom: 1px solid #555;
display: flex; gap: 15px; align-items: center; justify-content: center;
z-index: 100; flex-wrap: wrap;
}
.control-group { display: flex; align-items: center; gap: 5px; background: #333; padding: 2px 8px; border-radius: 4px; }
label { font-size: 12px; color: #ccc; }
button, select, input[type=number] {
background: #444; color: white; border: 1px solid #777;
padding: 4px 8px; cursor: pointer; font-size: 14px; border-radius: 4px;
}
button:hover, select:hover { background: #666; }
input[type=number] { width: 50px; text-align: center; }
input[type=range] { width: 100px; cursor: pointer; }
#status { font-size: 12px; color: #0ff; margin-left: 5px; min-width: 100px;}
.instruction {
font-size: 12px; color: #aaa; position: absolute; bottom: 10px; right: 20px; text-align: right;
line-height: 1.6; background: rgba(0,0,0,0.7); padding: 10px; border-radius: 8px; pointer-events: none;
}
.highlight { color: #ffeb3b; font-weight: bold; }
</style>
</head>
<body>
<div id="ui">
<div class="control-group">
<label>音色</label>
<select id="soundMode">
<option value="piano">🎹 Grand Piano</option>
<option value="guitar">🎸 Guitar</option>
<option value="fm">✨ FM Electric</option>
<option value="strings">🎻 Strings</option>
<option value="synth" selected>🎹 Synth</option>
</select>
</div>
<div class="control-group">
<label>速度 <span id="speedVal">1.0</span>x</label>
<input type="range" id="speedRange" min="0.1" max="2.0" step="0.1" value="1.0">
</div>
<div class="control-group">
<label>高さ</label>
<input type="number" id="areaHeightInput" min="1" max="10" value="3">
</div>
<input type="file" id="fileInput" accept=".mid,.midi" style="width: 180px;">
<button id="startBtn" disabled>再生スタート</button>
<div id="status">ファイル未選択</div>
</div>
<div class="instruction">
<span class="highlight">◆ 操作ガイド Ver 12.0</span><br>
[マウス追従] 再生バー移動<br>
[左右の端でドラッグ] <span class="highlight">スクロール</span> (時間移動)<br>
[中央でドラッグ] <span class="highlight">演奏</span> (空白は無視)<br>
[左右に揺らす] ビブラート<br>
[ドロップ/枠外] 全音即停止
</div>
<canvas id="gameCanvas"></canvas>
<script>
// --- 設定 ---
const CANVAS_WIDTH = 600;
const CANVAS_HEIGHT = window.innerHeight;
const NOTE_HEIGHT = 20;
const BASE_FALL_SPEED = 200;
const MIN_BAR_Y = 60;
const SCROLL_THRESHOLD = 2.0;
const SCROLL_AREA_WIDTH = 80; // ★左右のスクロール用エリアの幅
// --- グローバル変数 ---
let canvas = document.getElementById('gameCanvas');
let ctx = canvas.getContext('2d');
// ゲーム時間
let notes = [];
let isPlaying = false;
let gameTime = 0;
let lastFrameTime = 0;
let speedRate = 1.0;
// 操作状態
let isHolding = false;
let interactionMode = null; // 'play', 'scroll', or null
let targetY = CANVAS_HEIGHT - 150;
let targetHeight = NOTE_HEIGHT * 3;
let areaFlash = 0.0;
// 譜面の描画基準位置 (固定)
const ANCHOR_Y = CANVAS_HEIGHT - 150;
// スクロール・表現用
let lastMouseY = 0;
let holdStartX = 0;
let vibratoAmount = 0;
// 音響関連
let limiter;
let vibratoEffect;
const instruments = {};
let loadedSamples = { piano: false, guitar: false };
// UI Reference
const startBtn = document.getElementById('startBtn');
const statusDiv = document.getElementById('status');
const modeSelect = document.getElementById('soundMode');
const speedRange = document.getElementById('speedRange');
const speedVal = document.getElementById('speedVal');
const areaHeightInput = document.getElementById('areaHeightInput');
// --- 初期化 ---
window.onload = () => {
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
limiter = new Tone.Limiter(-1).toDestination();
vibratoEffect = new Tone.Vibrato({
frequency: 6, depth: 0, wet: 1
}).connect(limiter);
const connectToChain = (inst) => inst.connect(vibratoEffect);
// 楽器セットアップ
instruments.synth = new Tone.PolySynth(Tone.Synth, {
volume: -10, oscillator: { type: "triangle" },
envelope: { attack: 0.02, decay: 0.1, sustain: 0.3, release: 2 }
});
connectToChain(instruments.synth);
instruments.fm = new Tone.PolySynth(Tone.FMSynth, {
volume: -10, harmonicity: 3, modulationIndex: 10,
envelope: { attack: 0.01, decay: 0.2, sustain: 0.5, release: 2 }
});
connectToChain(instruments.fm);
instruments.strings = new Tone.PolySynth(Tone.Synth, {
volume: -10, oscillator: { type: "sawtooth" },
envelope: { attack: 0.4, decay: 0.5, sustain: 0.8, release: 3 }
});
connectToChain(instruments.strings);
instruments.piano = new Tone.Sampler({
urls: { "C4": "C4.mp3", "D#4": "Ds4.mp3", "F#4": "Fs4.mp3", "A4": "A4.mp3" },
release: 1.5, volume: -5,
baseUrl: "https://tonejs.github.io/audio/salamander/",
onload: () => { loadedSamples.piano = true; updateStatus(); }
});
connectToChain(instruments.piano);
instruments.guitar = new Tone.Sampler({
urls: { "C4": "C4.mp3", "E4": "E4.mp3", "G4": "G4.mp3" },
release: 2.0, volume: -5,
baseUrl: "https://tonejs.github.io/audio/guitar-acoustic/",
onload: () => { loadedSamples.guitar = true; updateStatus(); }
});
connectToChain(instruments.guitar);
requestAnimationFrame(gameLoop);
};
function updateStatus() {
const mode = modeSelect.value;
if ((mode === 'piano' && !loadedSamples.piano) ||
(mode === 'guitar' && !loadedSamples.guitar)) {
statusDiv.innerText = "音源ロード中...";
statusDiv.style.color = "#FF0";
} else {
statusDiv.innerText = notes.length > 0 ? (isPlaying ? "再生中" : "準備完了") : "ファイル待機";
statusDiv.style.color = "#0FF";
}
}
// --- メインループ ---
function gameLoop(timestamp) {
if (!lastFrameTime) lastFrameTime = timestamp;
const deltaTime = (timestamp - lastFrameTime) / 1000;
lastFrameTime = timestamp;
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// --- サイドエリア(スクロール領域)の可視化 ---
// ユーザーにここが「外」だとわかるように少し色を変える
ctx.fillStyle = "#181818";
ctx.fillRect(0, 0, SCROLL_AREA_WIDTH, CANVAS_HEIGHT);
ctx.fillRect(CANVAS_WIDTH - SCROLL_AREA_WIDTH, 0, SCROLL_AREA_WIDTH, CANVAS_HEIGHT);
ctx.strokeStyle = "#333";
ctx.beginPath();
ctx.moveTo(SCROLL_AREA_WIDTH, 0); ctx.lineTo(SCROLL_AREA_WIDTH, CANVAS_HEIGHT);
ctx.moveTo(CANVAS_WIDTH - SCROLL_AREA_WIDTH, 0); ctx.lineTo(CANVAS_WIDTH - SCROLL_AREA_WIDTH, CANVAS_HEIGHT);
ctx.stroke();
// 時間進行
if (isPlaying && !isHolding) {
gameTime += deltaTime * speedRate;
}
// --- 描画 ---
if (areaFlash > 0) areaFlash -= 0.05;
if (areaFlash < 0) areaFlash = 0;
// 再生エリア
let r = 0, g = 255, b = 200;
if (isHolding) {
if (interactionMode === 'play') { r = 255; g = 50; b = 150; }
else if (interactionMode === 'scroll') { r = 255; g = 165; b = 0; }
}
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${isHolding ? (0.3 + areaFlash * 0.4) : 0.2})`;
ctx.fillRect(0, targetY, CANVAS_WIDTH, targetHeight);
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${isHolding ? 0.8 : 0.5})`;
ctx.lineWidth = 2 + (vibratoAmount * 8);
ctx.strokeRect(0, targetY, CANVAS_WIDTH, targetHeight);
// ノート描画
const lastNote = notes[notes.length - 1];
if (isPlaying && lastNote && gameTime > lastNote.time + 3.0) {
stopGame();
}
notes.forEach(note => {
if (isPlaying) {
const timeDiff = note.time - gameTime;
note.y = ANCHOR_Y - (timeDiff * BASE_FALL_SPEED);
} else {
note.y = -100;
}
if (note.flash > 0) note.flash -= 0.05;
if (note.flash < 0) note.flash = 0;
if (note.y > -50 && note.y < CANVAS_HEIGHT) {
ctx.fillStyle = note.flash > 0 ? "#FFF" : note.color;
const wobble = (interactionMode === 'play' && isHolding && note.flash > 0)
? Math.sin(timestamp / 30) * vibratoAmount * 3 : 0;
ctx.fillRect(note.x + wobble, note.y, note.w, note.h);
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(0,0,0,0.5)";
ctx.strokeRect(note.x + wobble, note.y, note.w, note.h);
// ID表示: サイドエリア内に表示
if (note.shouldDrawId) {
ctx.fillStyle = "rgba(255, 255, 255, 0.5)"; // 少し控えめに
ctx.font = "20px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// 左端のサイドエリア中央
ctx.fillText(note.id, SCROLL_AREA_WIDTH / 2, note.y + note.h/2);
// 右端のサイドエリア中央
ctx.fillText(note.id, CANVAS_WIDTH - (SCROLL_AREA_WIDTH / 2), note.y + note.h/2);
}
}
});
requestAnimationFrame(gameLoop);
}
// --- 入力制御 ---
areaHeightInput.addEventListener('change', (e) => {
let val = parseInt(e.target.value);
if(val < 1) val = 1; if(val > 10) val = 10;
targetHeight = val * NOTE_HEIGHT;
});
speedRange.addEventListener('input', (e) => {
speedRate = parseFloat(e.target.value);
speedVal.innerText = speedRate.toFixed(1);
});
// マウス移動
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// バーの追従
targetY = my - (targetHeight / 2);
if(targetY < MIN_BAR_Y) targetY = MIN_BAR_Y;
if(targetY > CANVAS_HEIGHT - targetHeight - 10) targetY = CANVAS_HEIGHT - targetHeight - 10;
// ドラッグ中処理
if (isHolding) {
// ★サイドエリアを掴んでいる時だけスクロール
if (interactionMode === 'scroll') {
const deltaY = my - lastMouseY;
if (Math.abs(deltaY) > SCROLL_THRESHOLD) {
gameTime += deltaY * 0.01;
if(gameTime < 0) gameTime = 0;
}
}
// ★演奏モードの時だけビブラート
else if (interactionMode === 'play') {
const dist = Math.abs(mx - holdStartX);
let depth = (dist - 10) / 200;
if(depth < 0) depth = 0; if(depth > 1) depth = 1;
vibratoEffect.depth.rampTo(depth, 0.1);
vibratoAmount = depth;
}
// interactionMode === null (中央の空白を掴んだ) の場合は何もしない
}
lastMouseY = my;
});
canvas.addEventListener('mousedown', async (e) => {
await initAudioContext();
const rect = canvas.getBoundingClientRect();
const my = e.clientY - rect.top;
const mx = e.clientX - rect.left;
if (e.button === 0) { // 左クリック
isHolding = true;
targetY = my - (targetHeight / 2);
if(targetY < MIN_BAR_Y) targetY = MIN_BAR_Y;
if(targetY > CANVAS_HEIGHT - targetHeight - 10) targetY = CANVAS_HEIGHT - targetHeight - 10;
lastMouseY = my;
holdStartX = mx;
areaFlash = 1.0;
// ★エリア判定: 左右の端(スクロールエリア)か?
if (mx < SCROLL_AREA_WIDTH || mx > CANVAS_WIDTH - SCROLL_AREA_WIDTH) {
// タイムラインの外 -> スクロールモード
interactionMode = 'scroll';
} else {
// 中央エリア -> 演奏モード判定
const notesToPlay = [];
notes.forEach(note => {
const nBot = note.y + note.h;
const nTop = note.y;
const aTop = targetY;
const aBot = targetY + targetHeight;
if (nBot >= aTop && nTop <= aBot) {
notesToPlay.push(note);
}
});
if (notesToPlay.length > 0) {
interactionMode = 'play';
// ベロシティ計算
// 中央エリア内での相対位置を使う (SCROLL_AREA_WIDTH ~ CANVAS_WIDTH - SCROLL_AREA_WIDTH)
let playWidth = CANVAS_WIDTH - (SCROLL_AREA_WIDTH * 2);
let localX = mx - SCROLL_AREA_WIDTH;
let velocity = 0.1 + (localX / playWidth) * 0.9;
velocity = Math.max(0.1, Math.min(1.0, velocity));
notesToPlay.forEach(n => n.flash = 1.0);
triggerSoundAttack(notesToPlay, velocity);
} else {
// 中央かつノート無し -> 何もしない (誤操作防止のためスクロールもしない)
interactionMode = null;
}
}
}
else if (e.button === 2) {
notes.forEach(note => {
if (mx >= note.x && mx <= note.x + note.w &&
my >= note.y && my <= note.y + note.h) {
const inst = getCurrentInstrument();
if(inst) inst.triggerAttackRelease(note.name, "8n", Tone.now(), 0.8);
note.flash = 1.0;
}
});
}
});
function releaseAllSounds() {
if (!isHolding) return;
isHolding = false;
interactionMode = null;
vibratoEffect.depth.rampTo(0, 0.05);
vibratoAmount = 0;
Object.values(instruments).forEach(inst => {
if(inst) inst.releaseAll();
});
}
window.addEventListener('mouseup', releaseAllSounds);
canvas.addEventListener('mouseleave', releaseAllSounds);
window.addEventListener('keydown', (e) => { if (e.key === "Escape") stopGame(); });
canvas.addEventListener('contextmenu', e => e.preventDefault());
// --- 音声制御 ---
function getCurrentInstrument() {
const mode = modeSelect.value;
const inst = instruments[mode];
if ((mode === 'piano' && !loadedSamples.piano) ||
(mode === 'guitar' && !loadedSamples.guitar)) {
return instruments.synth;
}
return inst;
}
function triggerSoundAttack(targetNotes, velocity) {
const uniqueNotes = Array.from(new Set(targetNotes.map(n => n.name)));
const inst = getCurrentInstrument();
if(inst) {
const now = Tone.now();
uniqueNotes.forEach((noteName, index) => {
const strumble = index * 0.02;
inst.triggerAttack(noteName, now + strumble, velocity);
});
}
}
async function initAudioContext() {
if (Tone.context.state !== 'running') await Tone.start();
}
// --- ファイル・再生制御 ---
startBtn.addEventListener('click', async () => {
await initAudioContext();
if(notes.length === 0) return;
gameTime = 0;
isHolding = false;
Object.values(instruments).forEach(inst => inst.releaseAll());
let hVal = parseInt(areaHeightInput.value);
targetHeight = hVal * NOTE_HEIGHT;
isPlaying = true;
startBtn.innerText = "最初から再生";
statusDiv.innerText = "再生中...";
});
function stopGame() {
isPlaying = false;
releaseAllSounds();
startBtn.innerText = "最初から再生";
statusDiv.innerText = "停止中";
Object.values(instruments).forEach(inst => inst.releaseAll());
}
document.getElementById('fileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
statusDiv.innerText = "解析中...";
const reader = new FileReader();
reader.onload = function(ev) {
try {
const midiData = new Midi(ev.target.result);
notes = [];
const colors = ["#ff5252", "#448aff", "#69f0ae", "#ffd740", "#e040fb", "#536dfe"];
midiData.tracks.forEach((track, i) => {
track.notes.forEach(n => {
notes.push({
id: 0,
name: n.name, time: n.time,
x: SCROLL_AREA_WIDTH + (n.midi % 12) * 35, // 位置調整
y: -100,
w: 30, h: NOTE_HEIGHT,
color: colors[i % colors.length], flash: 0,
shouldDrawId: false
});
});
});
notes.sort((a,b) => a.time - b.time);
// ID重複対策
let currentId = 0;
let lastTime = -999;
notes.forEach(n => {
if (Math.abs(n.time - lastTime) > 0.1) {
currentId++;
lastTime = n.time;
n.shouldDrawId = true;
} else {
n.shouldDrawId = false;
}
n.id = currentId;
});
startBtn.disabled = false;
startBtn.innerText = "再生スタート";
updateStatus();
} catch(err) {
console.error(err);
statusDiv.innerText = "解析エラー";
}
};
reader.readAsArrayBuffer(file);
});
modeSelect.addEventListener('change', () => { updateStatus(); modeSelect.blur(); });
</script>
</body>
</html>

コメント