見出し画像

[自作WEBアプリ]Volca Drum Patch Chart

前回のChatGPTの大馬鹿野郎とのコラボに懲りて、今回は最初からClaude!!Presented by claude&meっすわ無論!!w

で、このアプリがなにかってえと、synthmataでvolca drumをエディットすると、スライダー方式なのでカーソルを持っていくと数字は出ますけど、逆に言えばカーソルを持っていかないと数字が出ないのです。するとスクショして数値を画像保存、ってのができないし、またsynthmataには数値入った画像エクスポート機能とかも無いんすわ。だったらこれこそ自分で作れるよな?と。ちゃっとじーぴーてぃーの大馬鹿野郎には無理でも、Claudeならできるよな?とw

で、できました。
ただ苦労した点もなかったわけではありません。volca drumの内部パラメーターは0−255だったりするのですが、MIDIのCCは0−127なので、まあ変換してるわけです。すると変換の法則ってのが出てくるんです。それをsynthmataは解明して作ってると思われるのですが、こちらはわかってないので、何度も試行しながら、これだと数字が1ズレている、ということはどうなる?みたいにクロード君と一緒にあれこれ考えながら頑張りました。

あとこれはこちらの責任ですが、Claude無課金故の時間的制約。待たされ。そんな焦らしプレイを存分に味わわせて頂きました笑

こちらが完成品です。synthmataでエディットした音色をその音色ごとに数値でアーカイブしたいなあ、という方はぜひ使ってみてください。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Volca Drum Patch Chart</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Inter:wght@400;600;700&display=swap');

*{box-sizing:border-box;margin:0;padding:0}
body{
  background:#f5f5f7;
  color:#111;
  font-family:'Inter',sans-serif;
  min-height:100vh;
  padding:24px;
}
h1{font-size:18px;font-weight:700;margin-bottom:20px;color:#111}
.app{display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
.left{
  flex:0 0 300px;
  background:#fff;
  border-radius:18px;
  padding:20px;
  box-shadow:0 4px 20px rgba(0,0,0,.06);
  display:flex;flex-direction:column;gap:14px;
}
.field label{
  display:block;font-size:11px;font-weight:600;color:#999;
  text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;
}
textarea{
  width:100%;height:160px;border:1px solid #ddd;border-radius:10px;
  font-family:'Share Tech Mono',monospace;font-size:11px;
  padding:10px;resize:vertical;color:#333;background:#fafafa;
}
textarea:focus{outline:none;border-color:#007aff}
input[type=text]{
  width:100%;padding:9px 12px;border:1px solid #ddd;border-radius:10px;
  font-family:'Inter',sans-serif;font-size:14px;color:#111;background:#fafafa;
}
input[type=text]:focus{outline:none;border-color:#007aff}
.part-btns{display:flex;gap:6px}
.part-btn{
  flex:1;padding:8px 4px;border:1px solid #ddd;border-radius:10px;
  background:#fff;color:#999;font-family:'Inter',sans-serif;
  font-weight:600;font-size:13px;cursor:pointer;transition:all .15s;
}
.part-btn:hover{border-color:#007aff;color:#007aff}
.part-btn.active{background:#007aff;color:#fff;border-color:#007aff}
.gen-btn{
  width:100%;padding:11px;background:#007aff;border:none;border-radius:12px;
  color:#fff;font-family:'Inter',sans-serif;font-weight:700;font-size:14px;
  cursor:pointer;transition:opacity .15s;
}
.gen-btn:hover{opacity:.85}
.save-btn{
  width:100%;padding:9px;background:transparent;border:1px solid #007aff;
  border-radius:12px;color:#007aff;font-family:'Inter',sans-serif;
  font-weight:600;font-size:14px;cursor:pointer;display:none;transition:all .15s;
}
.save-btn:hover{background:#007aff;color:#fff}
.copy-btn{
  width:100%;padding:9px 12px;background:#f0f6ff;border:1px solid #c0d8ff;
  border-radius:10px;color:#007aff;font-family:'Inter',sans-serif;
  font-weight:600;font-size:13px;cursor:pointer;text-align:left;transition:all .15s;
}
.copy-btn:hover{background:#007aff;color:#fff;border-color:#007aff}
.copy-btn.copied{background:#34c759;border-color:#34c759;color:#fff}
.err{color:#ff3b30;font-size:11px;margin-top:4px;display:none}
.right{flex:1;min-width:0}
canvas{
  display:block;border-radius:18px;
  box-shadow:0 4px 20px rgba(0,0,0,.08);max-width:100%;
}
</style>
</head>
<body>

<h1>🥁 Volca Drum · Patch Chart</h1>

<div class="app">
  <div class="left">
    <div class="field">
      <label>① スクリプトをコピー → synthmataのコンソールで実行 → JSONを貼り付け</label>
      <button class="copy-btn" id="copy_btn" onclick="copyScript()">📋 コンソールスクリプトをコピー</button>
    </div>
    <div class="field">
      <label>② Console JSON</label>
      <textarea id="json_input" placeholder="synthmataのコンソールJSONを貼り付け..."></textarea>
      <div id="err" class="err"></div>
    </div>
    <div class="field">
      <label>パート選択</label>
      <div class="part-btns">
        <button class="part-btn active" data-part="1">P1</button>
        <button class="part-btn" data-part="2">P2</button>
        <button class="part-btn" data-part="3">P3</button>
        <button class="part-btn" data-part="4">P4</button>
        <button class="part-btn" data-part="5">P5</button>
        <button class="part-btn" data-part="6">P6</button>
      </div>
    </div>
    <div class="field">
      <label>パッチ名</label>
      <input type="text" id="patch_name" placeholder="例: A1 Kick">
    </div>
    <button class="gen-btn" onclick="generate()">Generate</button>
    <button class="save-btn" id="save_btn" onclick="saveImage()">Save PNG</button>
  </div>

  <div class="right">
    <canvas id="canvas"></canvas>
  </div>
</div>

<script>
// ── コンソールスクリプト ──────────────────────────────
const CONSOLE_SCRIPT = `const all = document.querySelectorAll('[data-cclsb]');
const result = [];
all.forEach(p => {
  let val;
  if(p.type === 'radio') {
    val = p.checked ? p.value : null;
  } else if(p.type === 'checkbox') {
    val = p.checked ? 1 : 0;
  } else {
    val = p.value;
  }
  if(p.type !== 'radio' || p.checked) {
    result.push({
      id: p.id || p.name,
      type: p.type,
      cclsb: p.dataset.cclsb,
      value: val
    });
  }
});
console.log(JSON.stringify(result, null, 2));`;

function copyScript() {
  navigator.clipboard.writeText(CONSOLE_SCRIPT).then(() => {
    const btn = document.getElementById('copy_btn');
    btn.textContent = '✅ コピーしました!';
    btn.classList.add('copied');
    setTimeout(() => {
      btn.textContent = '📋 コンソールスクリプトをコピー';
      btn.classList.remove('copied');
    }, 2000);
  });
}

// ── パート選択 ─────────────────────────────────────────
let selectedPart = 1;
document.querySelectorAll('.part-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    document.querySelectorAll('.part-btn').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    selectedPart = parseInt(btn.dataset.part);
  });
});

// ── CC値変換 ───────────────────────────────────────────
// 0〜255スケール: CC*2、CC=127のみ255
const to255 = cc => cc === 127 ? 255 : cc * 2;

// -100〜+100スケール ルックアップテーブル(設定不可能な値を除外済み)
// 設定不可能: -99,-96,-94,-91...(実機で奇数が出ない等の制約)
const LOOKUP_100S = [
  -100,-98,-97,-95,-93,-92,-90,-89,-87,-86,-84,-83,-81,-79,-78,-76,
  -75,-73,-72,-70,-68,-67,-65,-64,-62,-61,-59,-58,-56,-54,-53,-51,
  -50,-48,-47,-45,-43,-42,-40,-39,-37,-36,-34,-33,-31,-29,-28,-26,
  -25,-23,-22,-20,-18,-17,-15,-14,-12,-11,-9,-8,-6,-4,-3,-1,
  0,2,3,5,5,8,10,11,13,14,16,17,19,21,22,24,
  25,27,28,30,32,33,35,36,38,39,41,42,44,46,47,49,
  50,52,53,55,57,58,60,61,63,64,67,67,69,71,72,75,
  75,78,78,80,83,83,86,86,89,89,92,94,94,97,97,100
];
const to100s = cc => LOOKUP_100S[Math.max(0, Math.min(127, cc))];

// GAINスケール: CC=64→0, CC=127→100(同じ制約でルックアップ)
const LOOKUP_GAIN = [
  -100,-100,-98,-97,-95,-93,-92,-90,-89,-87,-86,-84,-83,-81,-79,-78,
  -76,-75,-73,-72,-70,-68,-67,-65,-64,-61,-61,-58,-58,-56,-53,-53,
  -50,-50,-47,-47,-45,-42,-42,-39,-39,-36,-34,-33,-31,-31,-28,-26,
  -25,-23,-22,-20,-20,-17,-15,-14,-12,-11,-9,-8,-6,-4,-3,-1,
  0,0,3,3,5,7,8,11,11,14,14,17,19,19,22,22,
  25,25,28,30,30,33,33,36,38,39,41,42,44,46,47,49,
  50,52,53,55,57,58,60,61,63,64,66,67,69,71,72,74,
  75,77,78,80,82,83,85,86,88,89,92,92,94,96,97,100
];
const to100g = cc => LOOKUP_GAIN[Math.max(0, Math.min(127, cc))];

// ── セレクター定義 ─────────────────────────────────────
const SRC_LABELS = ['SINE','SAW','HPN','LPN','BPN'];
const SRC_CC     = [0, 26, 52, 78, 103];
const MOD_LABELS = ['R&F','OSC','RDM'];
const MOD_CC     = [0, 27, 18];
const AEG_LABELS = ['LAR','EAR','MPAR'];
const AEG_CC     = [0, 3, 6];

function nearest(ccList, val) {
  return ccList.reduce((best,c,i) =>
    Math.abs(c-val) < Math.abs(ccList[best]-val) ? i : best, 0);
}

// ── パラメーター抽出 ───────────────────────────────────
function extractPart(params, pNum) {
  const p = `p${pNum}`;
  const get  = id => { const x = params.find(e => e.id === id); return x ? (parseInt(x.value)||0) : 0; };
  const getW = l  => { const w = params.find(e => e.id.startsWith(`${p}select${l}wave`)); return w ? parseInt(w.value)||0 : 0; };
  const getM = l  => { const m = params.find(e => e.id.startsWith(`${p}select${l}mod`));  return m ? parseInt(m.value)||0 : 0; };
  const getE = l  => { const v = params.find(e => e.id.startsWith(`${p}select${l}env`));  return v ? parseInt(v.value)||0 : 0; };

  return {
    srcIdx1: nearest(SRC_CC, getW(1)),
    modIdx1: nearest(MOD_CC, getM(1)),
    aegIdx1: nearest(AEG_CC, getE(1)),
    level1:   to255(get(`${p}level1`)),
    pitch1:   to255(get(`${p}pitch1`)),
    attack1:  to255(get(`${p}egattack1`)),
    release1: to255(get(`${p}egrelease1`)),
    modamt1:  to100s(get(`${p}modamt1`)),
    modrate1: to255(get(`${p}modrate1`)),

    srcIdx2: nearest(SRC_CC, getW(2)),
    modIdx2: nearest(MOD_CC, getM(2)),
    aegIdx2: nearest(AEG_CC, getE(2)),
    level2:   to255(get(`${p}level2`)),
    pitch2:   to255(get(`${p}pitch2`)),
    attack2:  to255(get(`${p}egattack2`)),
    release2: to255(get(`${p}egrelease2`)),
    modamt2:  to100s(get(`${p}modamt2`)),
    modrate2: to255(get(`${p}modrate2`)),

    send:       to255(get(`${p}send`)),
    bitred:     to255(get(`${p}bitred`)),
    fold:       to255(get(`${p}fold`)),
    drive:      to255(get(`${p}drive`)),
    pan:        to100s(get(`${p}pan`)),
    gain:       to100g(get(`${p}gain`)),
    pitchquant: get(`${p}pitchquant`) ? 'ON' : 'OFF',
  };
}

// ── Generate ───────────────────────────────────────────
function generate() {
  const raw   = document.getElementById('json_input').value.trim();
  const errEl = document.getElementById('err');
  errEl.style.display = 'none';
  if (!raw) { errEl.textContent = 'JSONを貼り付けてください'; errEl.style.display='block'; return; }
  let params;
  try { params = JSON.parse(raw); }
  catch(e) { errEl.textContent = 'JSON形式エラー: '+e.message; errEl.style.display='block'; return; }
  const d    = extractPart(params, selectedPart);
  const name = document.getElementById('patch_name').value.trim() || `PART ${selectedPart}`;
  drawChart(d, name, selectedPart);
  document.getElementById('save_btn').style.display = 'block';
}

// ── Canvas描画 ────────────────────────────────────────
function drawChart(d, name, pNum) {
  const canvas = document.getElementById('canvas');
  const DPR = window.devicePixelRatio || 1;
  const W = 580, H = 370;
  canvas.width  = W * DPR;
  canvas.height = H * DPR;
  canvas.style.width  = W + 'px';
  canvas.style.height = H + 'px';
  const ctx = canvas.getContext('2d');
  ctx.scale(DPR, DPR);

  const WHITE  = '#ffffff';
  const BG     = '#f5f5f7';
  const BLUE   = '#007aff';
  const TEXT   = '#111111';
  const SUB    = '#888888';
  const LINE   = '#e5e5ea';
  const SEL_BG = '#e8f0ff';

  const mono = s => `${s}px 'Share Tech Mono', monospace`;
  const sans = (s,w=400) => `${w} ${s}px 'Inter', sans-serif`;

  function roundRect(ctx, x, y, w, h, r) {
    ctx.beginPath();
    ctx.moveTo(x+r,y); ctx.lineTo(x+w-r,y); ctx.arcTo(x+w,y,x+w,y+r,r);
    ctx.lineTo(x+w,y+h-r); ctx.arcTo(x+w,y+h,x+w-r,y+h,r);
    ctx.lineTo(x+r,y+h); ctx.arcTo(x,y+h,x,y+h-r,r);
    ctx.lineTo(x,y+r); ctx.arcTo(x,y,x+r,y,r);
    ctx.closePath();
  }

  // 背景
  ctx.fillStyle = WHITE;
  roundRect(ctx, 0, 0, W, H, 18); ctx.fill();

  // ヘッダー帯
  ctx.fillStyle = BG;
  roundRect(ctx, 0, 0, W, 46, 18); ctx.fill();
  ctx.fillStyle = LINE;
  ctx.fillRect(0, 46, W, 1);

  // パッチ名
  ctx.font = sans(18, 700);
  ctx.fillStyle = TEXT;
  ctx.fillText(name, 18, 30);

  // PART番号
  ctx.font = sans(11, 600);
  ctx.fillStyle = BLUE;
  ctx.textAlign = 'right';
  ctx.fillText(`PART ${pNum}`, W-18, 30);
  ctx.textAlign = 'left';

  // セレクター描画
  function drawSelector(labels, selIdx, x, y) {
    const cellH = 22, gap = 4, totalW = 240;
    const cellW = Math.floor((totalW - gap*(labels.length-1)) / labels.length);
    labels.forEach((lbl, i) => {
      const cx = x + i*(cellW+gap);
      const sel = i === selIdx;
      ctx.fillStyle   = sel ? SEL_BG : '#f0f0f2';
      ctx.strokeStyle = sel ? BLUE : 'transparent';
      ctx.lineWidth   = sel ? 1.5 : 0;
      roundRect(ctx, cx, y, cellW, cellH, 5);
      ctx.fill();
      if (sel) ctx.stroke();
      ctx.font = `${sel?'600':'400'} 10px 'Inter', sans-serif`;
      ctx.fillStyle = sel ? BLUE : '#aaa';
      ctx.textAlign = 'center';
      ctx.fillText(lbl, cx + cellW/2, y + 14);
      ctx.textAlign = 'left';
    });
  }

  function secLabel(text, x, y) {
    ctx.font = sans(10, 600); ctx.fillStyle = SUB; ctx.fillText(text, x, y);
  }

  function paramBlock(lbl, val, x, y) {
    ctx.font = sans(9, 400); ctx.fillStyle = '#bbb'; ctx.fillText(lbl, x, y);
    ctx.font = mono(16); ctx.fillStyle = TEXT; ctx.fillText(String(val), x, y+20);
  }

  // レイアウト定数
  const PAD  = 18;
  const MIDX = W / 2;
  const COL1 = PAD;
  const COL2 = MIDX + PAD;
  const COL_W = MIDX - PAD * 2;

  let y = 58;

  // レイヤーヘッダー
  secLabel('LAYER 1', COL1, y + 10);
  secLabel('LAYER 2', COL2, y + 10);
  y += 16;

  // SRC行
  ctx.font = sans(9,400); ctx.fillStyle='#ccc';
  ctx.fillText('SRC', COL1, y+15);
  drawSelector(SRC_LABELS, d.srcIdx1, COL1+28, y+3);
  ctx.fillText('SRC', COL2, y+15);
  drawSelector(SRC_LABELS, d.srcIdx2, COL2+28, y+3);
  y += 30;

  // MOD行
  ctx.fillText('MOD', COL1, y+15);
  drawSelector(MOD_LABELS, d.modIdx1, COL1+28, y+3);
  ctx.fillText('MOD', COL2, y+15);
  drawSelector(MOD_LABELS, d.modIdx2, COL2+28, y+3);
  y += 30;

  // AEG行
  ctx.fillText('AEG', COL1, y+15);
  drawSelector(AEG_LABELS, d.aegIdx1, COL1+28, y+3);
  ctx.fillText('AEG', COL2, y+15);
  drawSelector(AEG_LABELS, d.aegIdx2, COL2+28, y+3);
  y += 36;

  // 区切り
  ctx.fillStyle = LINE; ctx.fillRect(PAD, y, W-PAD*2, 1); y += 14;

  // 数値パラメーター(3列×2行)
  const numColW = Math.floor(COL_W / 3);
  const ROW_H   = 44;

  [
    ['LEVEL',   d.level1],
    ['PITCH',   d.pitch1],
    ['ATTACK',  d.attack1],
    ['RELEASE', d.release1],
    ['MOD AMT', d.modamt1],
    ['MOD RATE',d.modrate1],
  ].forEach(([lbl,val], i) => {
    paramBlock(lbl, val, COL1 + (i%3)*numColW, y + Math.floor(i/3)*ROW_H);
  });

  [
    ['LEVEL',   d.level2],
    ['PITCH',   d.pitch2],
    ['ATTACK',  d.attack2],
    ['RELEASE', d.release2],
    ['MOD AMT', d.modamt2],
    ['MOD RATE',d.modrate2],
  ].forEach(([lbl,val], i) => {
    paramBlock(lbl, val, COL2 + (i%3)*numColW, y + Math.floor(i/3)*ROW_H);
  });

  y += ROW_H * 2 + 10;

  // 縦仕切り
  ctx.fillStyle = LINE;
  ctx.fillRect(MIDX, 46, 1, y - 46);

  // 横区切り
  ctx.fillRect(PAD, y, W-PAD*2, 1); y += 14;

  // PROCESSING
  secLabel('PROCESSING', COL1, y + 10); y += 16;

  const procParams = [
    ['WG SEND', d.send],
    ['BIT RED',  d.bitred],
    ['FOLD',    d.fold],
    ['DRIVE',   d.drive],
    ['PAN',     d.pan],
    ['GAIN',    d.gain],
    ['PITCH Q', d.pitchquant],
  ];
  const procW = Math.floor((W - PAD*2) / procParams.length);
  procParams.forEach(([lbl,val], i) => {
    const isStr = typeof val === 'string';
    const x = PAD + i * procW;
    ctx.font = sans(9,400); ctx.fillStyle='#bbb'; ctx.fillText(lbl, x, y);
    ctx.font = mono(16);
    ctx.fillStyle = isStr ? (val==='ON' ? BLUE : '#ccc') : TEXT;
    ctx.fillText(String(val), x, y+20);
  });

  y += 36;

  // フッター
  ctx.font = sans(9,400); ctx.fillStyle='#ddd';
  ctx.textAlign = 'right';
  ctx.fillText(new Date().toISOString().slice(0,10), W-18, y+6);
  ctx.textAlign = 'left';
}

// ── Save PNG ───────────────────────────────────────────
function saveImage() {
  const canvas = document.getElementById('canvas');
  const a = document.createElement('a');
  const name = document.getElementById('patch_name').value.trim() || `part${selectedPart}`;
  a.download = `${name.replace(/\s+/g,'_')}_P${selectedPart}.png`;
  a.href = canvas.toDataURL('image/png');
  a.click();
}
</script>
</body>
</html>

使用方法

まあアプリ内に書いているそのままですが、①コンソールスクリプトをコピーボタンを押しておいて、synthmataの画面でF12コンソールを出して、ペーストしてエンターして、出てきた数値をコピって(下にcopyボタンが出てきますのでそれクリックするだけでコピれます)②CONSOLE JSONに貼り付けして、画像出したいパートをP1〜P6で選択してGenerateボタンを押します。

すると、右側にチャート画像が表示されます。
あとはまあご自由に、です。自分の場合は昨日作成したvolca drum librarianにこの画像を入れてアーカイブしておく、そのためにこれを作った、ということなのです。

まあこの数値画像があれば、音色単体での他人との共有ということについては何かと有用だと思います。Youtube動画とかにしても良いと思いますし。

※ライブラリアンはこちら↓にてダウンロードできます。

しかしクロード君にも言ったのですが、ここまで頑張って法則を解明したら、もうエディター自体も出来そうなものです。まあsynthmataがある以上それで良いのですが、一点欲しい機能があって、それはパートコピー。part1に入力した音色を、Part6にコピーしたり入れ替えしたりしたいなあ、というもの。次はこれをやります。クロード君ならできるはずです笑

いいなと思ったら応援しよう!