<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>RhythmStudio — v11 Fixed</title>
<style>
  :root{
    --bg:#071126; --panel:#0e1830; --accent:#6ad1ff; --text:#e8f0ff;
    --good:#7ef29a; --warn:#ffb265; --bad:#ff8b8b;
  }
  *{box-sizing:border-box}
  body{margin:0;font-family:system-ui,Segoe UI,Roboto,Arial;background:linear-gradient(180deg,var(--bg),#041022);color:var(--text)}
  .header{display:flex;align-items:center;padding:10px 14px;gap:12px;border-bottom:1px solid rgba(255,255,255,0.03)}
  .btn{background:#0f2338;border:1px solid #233a5a;padding:6px 10px;border-radius:8px;color:var(--text);cursor:pointer}
  .container{display:grid;grid-template-columns:420px 1fr;gap:12px;padding:12px}
  .card{background:var(--panel);padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,0.03)}
  .row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
  .small{font-size:13px;color:#9fb1d9}
  .canvasWrap{background:#041029;border-radius:12px;padding:8px;position:relative}
  canvas#stage{width:100%;height:560px;border-radius:8px;background:linear-gradient(180deg,#031022,#041029)}
  #timeline{height:120px;background:#06162a;border-radius:8px;margin-top:10px;position:relative;overflow:hidden}
  .note-chip{position:absolute;padding:4px 6px;border-radius:6px;background:#12314f;color:#d7eaff;font-size:12px;border:1px solid rgba(255,255,255,0.03);cursor:pointer}
  .input{background:#07162a;border:1px solid #12324a;padding:6px;color:var(--text);border-radius:6px}
  .pill{background:#051428;padding:6px;border-radius:8px;border:1px solid #12324a}
  .inspector{margin-top:12px;padding:8px;border-radius:8px;background:#061a2b;border:1px solid rgba(255,255,255,0.03)}
  .progressOuter{width:100%;height:14px;background:#0b2233;border-radius:6px;overflow:hidden;border:1px solid rgba(255,255,255,0.03)}
  .progressInner{height:100%;background:linear-gradient(90deg,#7be0ff,#7effb3);width:0%;}
  .bigJudge{position:absolute;left:50%;transform:translateX(-50%);font-weight:900;pointer-events:none}
  .large-num{font-size:20px;font-weight:800}
  .note-editor-row{display:flex;gap:6px;align-items:center;margin-bottom:6px}
  input[type="number"]{width:100%;}
  label.hint{font-size:12px;color:#9fb1d9}
</style>
</head>
<body>

  <div class="header">
    <div style="font-weight:700">RhythmStudio — v11 Fixed</div>
    <div style="flex:1"></div>

    <div class="small">Mode:</div>
    <div>
      <label class="small"><input type="radio" name="mode" value="test" id="modeTest" checked> Test</label>
      <label class="small" style="margin-left:8px"><input type="radio" name="mode" value="play" id="modePlay"> Play</label>
    </div>

    <div style="width:12px"></div>
    <div class="small">Last Hit: <strong id="lastHit">—</strong></div>
    <div style="width:12px"></div>
    <div class="small">Multiplier: <strong id="topMult">1</strong></div>

    <label class="small" style="margin-left:8px">Rate</label>
    <input id="playRate" type="range" min="0.3" max="3.0" step="0.1" value="1.0" style="width:150px">
    <div id="rateVal" class="small" style="width:48px;text-align:center">1.0x</div>
  </div>

  <div class="container">
    <aside class="card">
      <div style="font-weight:700;margin-bottom:8px">Editor / Controls</div>

      <div class="row"><label class="small">Upload Music</label><input id="audioFile" type="file" accept="audio/*" class="input"></div>
      <div class="row"><label class="small">Import Chart (JSON)</label><input id="chartFile" type="file" accept="application/json" class="input"></div>
      <div class="row"><label class="small">Export Chart</label><button id="exportBtn" class="btn">Export JSON</button></div>

      <div class="row"><label class="small">Lanes</label><input id="laneCount" type="number" min="1" max="8" value="4" class="input" style="width:70px"></div>
      <div class="row"><label class="small">Default thickness</label><input id="globalThickness" type="number" value="6" step="0.5" class="input" style="width:80px"></div>
      <div class="row"><label class="small">BaseHalf px</label><input id="baseHalf" type="number" value="12" class="input" style="width:80px"></div>

      <div id="laneSpeedRows"></div>

      <div style="height:8px"></div>
      <div class="row">
        <button id="btnAddTap" class="btn">Add Tap</button>
        <button id="btnAddHold" class="btn">Add Hold</button>
        <button id="btnAddChord" class="btn">Add Chord</button>
        <button id="btnAddSeq" class="btn">Add Seq</button>
        <button id="btnAddFunc" class="btn">Add Func</button>
      </div>

      <div style="height:8px"></div>
      <div style="font-weight:700">Judgement windows (ms)</div>
      <div class="row small"><label>wonderful</label><input id="w" type="number" value="20" class="input" style="width:80px"></div>
      <div class="row small"><label>perfect+</label><input id="pp" type="number" value="40" class="input" style="width:80px"></div>
      <div class="row small"><label>perfect</label><input id="p" type="number" value="80" class="input" style="width:80px"></div>
      <div class="row small"><label>good</label><input id="g" type="number" value="140" class="input" style="width:80px"></div>
      <div class="row small"><label>miss late</label><input id="late" type="number" value="200" class="input" style="width:80px"></div>

      <div style="height:12px"></div>
      <div style="font-weight:700">Note Inspector</div>
      <div id="inspector" class="inspector">
        <div class="small">Click a note to edit (no dialog)</div>
        <div id="inspectorForm" style="display:none">
          <div class="note-editor-row"><label class="hint">Type</label>
            <select id="ins_type" class="input">
              <option>tap</option><option>hold</option><option>chord</option><option>seq</option><option>func</option>
            </select>
          </div>
          <div class="note-editor-row"><label class="hint">Time (s)</label><input id="ins_time" class="input" type="number" step="0.001"></div>
          <div class="note-editor-row" id="ins_d_row"><label class="hint">Duration (s)</label><input id="ins_d" class="input" type="number" step="0.001"></div>
          <div class="note-editor-row"><label class="hint">Keys</label><input id="ins_keys" class="input" placeholder="A+B"></div>
          <div class="note-editor-row"><label class="hint">Thickness</label><input id="ins_th" class="input" type="number" step="0.1"></div>
          <div class="note-editor-row"><label class="hint">Color</label><input id="ins_color" class="input" placeholder="#hex"></div>
          <div class="note-editor-row"><label class="hint">Func payload (JSON)</label></div>
          <textarea id="ins_func" style="width:100%;height:80px;background:#051427;color:#dfefff;border-radius:8px;border:1px solid #12324a"></textarea>
          <div style="display:flex;gap:8px;margin-top:8px"><button id="ins_apply" class="btn">Apply</button><button id="ins_del" class="btn">Delete</button></div>
        </div>
      </div>

      <div style="height:12px"></div>
      <div style="font-weight:700">Multiplier Charge</div>
      <div class="small">Next multiplier progress</div>
      <div class="progressOuter" style="margin-top:6px"><div id="multProgress" class="progressInner" style="width:0%"></div></div>

      <div style="height:12px"></div>
      <div class="small">Tips:<br>? Click note to edit in inspector. Press '/' to add Tap at current time. Left/Right arrows seek (Shift for fine step). In Test mode you can pause and rewind and re-play notes.</div>
    </aside>

    <section class="card">
      <div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
        <div class="pill">Score: <span id="score">0</span></div>
        <div class="pill">Combo pts: <span id="combo">0</span></div>
        <div class="pill">Multiplier: <span id="mult">1</span></div>
        <div style="flex:1"></div>
        <div id="timeDisp" class="small">00:00.000</div>
        <button id="startBtn" class="btn">Start</button>
        <button id="pauseBtn" class="btn">Pause/Resume</button>
        <button id="resetBtn" class="btn">Reset</button>
      </div>

      <div class="canvasWrap">
        <canvas id="stage"></canvas>
        <div id="timeline"></div>
        <div style="margin-top:8px;display:flex;gap:8px;align-items:center">
          <input id="seek" type="range" min="0" max="0" step="0.001" style="flex:1">
          <input id="timeInput" class="input" style="width:120px" value="00:00.000">
        </div>
      </div>
    </section>
  </div>

  <audio id="audio" controls style="display:none"></audio>

<script>
/* =========================
   v11 Fixed — based on your uploaded v11 docx. Reference: uploaded file.  [oai_citation:1?!doctype html 11.docx](file-service://file-H6JS7McHneTwt9NaWHUJ2G)
   Fixes:
   - Hold judgement: judged at press (based on bottom-to-line); not re-judged at end. Hold stays visible while pressed.
   - Seq reset on seek/reset fixed.
   - func notes apply to effective lane config used for both display & judgement.
   - Fix inverted/backwards movement by consistent y mapping.
   - Keep Inspector-only edits (no dialog).
   ========================= */

const COLORS = ['#6ad1ff','#87f29e','#ffb265','#b38cff','#ff8fb3','#ffd36b'];
const BASE_SCORES = { miss:0, good:10, perfect:30, 'perfect+':50, wonderful:60 };
const MULT_THRESHOLD = 30;
const MULT_MAX = 4;

let chart = { title:'untitled', lanes:[] };
let nextId = 1;

const audio = document.getElementById('audio');
let devicePR = window.devicePixelRatio || 1;

let defaultThickness = parseFloat(document.getElementById('globalThickness').value) || 6;
let baseHalf = parseFloat(document.getElementById('baseHalf').value) || 12;

let pressed = new Set();
let lastPressTimes = {};
let selectedNote = null;

let judgew = { w:20, pp:40, p:80, g:140, late:200 };

let score = 0, combo = 0, mult = 1, multBucket = 0, streak = 0;
let mode = 'test';
let layout = {};
let judgeLineOffsetMultiplier = 2.5;

const canvas = document.getElementById('stage'), ctx = canvas.getContext('2d');

function resizeCanvas(){
  const r = canvas.getBoundingClientRect();
  canvas.width = Math.floor(r.width * devicePR);
  canvas.height = Math.floor(r.height * devicePR);
  ctx.setTransform(devicePR,0,0,devicePR,0,0);
  layout = { x:18, y:18, w: r.width-36, h: r.height-36, judgeYBase: 18 + (r.height-36)*0.9 };
  layout.judgeY = Math.min(r.height-6, (r.height-36) - 60); // keep judge line near bottom but stable
}
window.addEventListener('resize', ()=>{ resizeCanvas(); draw(); });
resizeCanvas();

function ensureLanes(n=4){
  if(!chart.lanes) chart.lanes = [];
  while(chart.lanes.length < n) chart.lanes.push({ keys:[String.fromCharCode(65+chart.lanes.length)], speed:600, notes:[] });
  while(chart.lanes.length > n) chart.lanes.pop();
}
ensureLanes(4);

function formatTime(sec){ if(!isFinite(sec)) return '00:00.000'; const mm=Math.floor(sec/60), ss=Math.floor(sec%60), ms=Math.floor((sec*1000)%1000); return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}.${String(ms).padStart(3,'0')}`; }
function hexToRgba(hex,a=1){ const c=(hex||'#6ad1ff').replace('#',''); const r=parseInt(c.substring(0,2),16); const g=parseInt(c.substring(2,4),16); const b=parseInt(c.substring(4,6),16); return `rgba(${r},${g},${b},${a})`; }
function normalizeKey(k){ if(!k) return ''; return String(k).length===1 ? String(k).toUpperCase() : String(k).toUpperCase(); }
function getLongestTime(){ let m=0; chart.lanes.forEach(l=> (l.notes||[]).forEach(n=>{ m = Math.max(m, n.t + (n.d||0)); })); return m; }

document.getElementById('audioFile').addEventListener('change', e=>{
  const f = e.target.files[0]; if(!f) return;
  if(audio.src) try{ URL.revokeObjectURL(audio.src); }catch(e){}
  audio.src = URL.createObjectURL(f);
  audio.load();
  audio.addEventListener('loadedmetadata', function onm(){ audio.removeEventListener('loadedmetadata', onm); seek.max = audio.duration || 0; rebuildTimeline(); draw(); });
});

document.getElementById('chartFile').addEventListener('change', async e=>{
  const f = e.target.files[0]; if(!f) return;
  const txt = await f.text();
  try{
    const obj = JSON.parse(txt);
    chart = obj; ensureLanes(Math.max(chart.lanes.length,4));
    chart.lanes.forEach(l=> l.notes = (l.notes||[]).map(n=>({ id:n.id||nextId++, type:n.type||'tap', t:+n.t||0, d:n.d||0, lane:n.lane||0, keys:n.keys||null, thickness:n.thickness||defaultThickness, colorIdx:n.colorIdx||0, customColor:n.customColor||null, hit:undefined, holdProgress:0, func:n.func||null })));
    rebuildLaneSpeedUI(); rebuildTimeline(); draw();
  }catch(err){ alert('Invalid JSON'); }
});

document.getElementById('exportBtn').addEventListener('click', ()=>{
  if(!chart) return;
  const out = JSON.stringify(chart, null, 2);
  const blob = new Blob([out], { type:'application/json' });
  const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (chart.title||'chart')+'.json'; a.click();
});

const laneSpeedRows = document.getElementById('laneSpeedRows');
function rebuildLaneSpeedUI(){
  laneSpeedRows.innerHTML = '';
  chart.lanes.forEach((lane,i)=>{
    const row = document.createElement('div'); row.className='row';
    const lab = document.createElement('div'); lab.className='small'; lab.textContent = `Lane ${i+1} keys`;
    const keysInp = document.createElement('input'); keysInp.className='input'; keysInp.value = (lane.keys||[]).join('+');
    keysInp.addEventListener('change', ()=>{ lane.keys = keysInp.value.split(/\s*\+\s*/).filter(Boolean).map(s=>s.toUpperCase()); rebuildTimeline(); draw(); });
    const speedInp = document.createElement('input'); speedInp.className='input'; speedInp.type='number'; speedInp.style.width='90px'; speedInp.value = lane.speed || 600;
    speedInp.addEventListener('change', ()=>{ lane.speed = parseFloat(speedInp.value||600); draw(); });
    row.appendChild(lab); row.appendChild(keysInp); row.appendChild(speedInp);
    laneSpeedRows.appendChild(row);
  });
}
rebuildLaneSpeedUI();

const timeline = document.getElementById('timeline');

function rebuildTimeline(){
  timeline.innerHTML = '';
  const dur = audio.duration || Math.max(10, getLongestTime()+2);
  for(let s=0;s<Math.ceil(dur);s+=1){
    const v = document.createElement('div'); v.style.position='absolute'; v.style.left = (s/dur*100)+'%'; v.style.top='0'; v.style.bottom='0'; v.style.width='1px'; v.style.background = (s%5===0) ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.01)'; timeline.appendChild(v);
  }
  chart.lanes.forEach((lane,i)=>{
    (lane.notes||[]).forEach(note=>{
      const chip = document.createElement('div'); chip.className='note-chip'; chip.dataset.id = note.id;
      chip.style.top = (6 + i*22) + 'px';
      const left = ((note.t / (dur || 1)) * 100);
      chip.style.left = left + '%';
      chip.textContent = note.type === 'tap' ? (note.keys ? note.keys.join('+') : 'Tap') : (note.type==='hold' ? 'Hold' : (note.keys ? ((note.type==='seq')?note.keys.join('→'):note.keys.join('+')) : note.type));
      chip.style.background = note.customColor || COLORS[(note.colorIdx||0) % COLORS.length];
      chip.addEventListener('click', ev => { ev.stopPropagation(); selectNoteById(note.id); showInspector(note); });
      timeline.appendChild(chip);
    });
  });
  const cur = document.createElement('div'); cur.id='cursor'; cur.style.position='absolute'; cur.style.top='0'; cur.style.bottom='0'; cur.style.width='2px'; cur.style.background='#6ad1ff';
  timeline.appendChild(cur);
  updateTimelineCursor();
}

function parseTimeInput(str){
  if(!str) return 0;
  str = String(str).trim();
  if(str === '/') return audio.currentTime || 0;
  if(/^\d+:\d+\.\d+$/.test(str)){ const [mm, rest] = str.split(':'); return parseInt(mm)*60 + parseFloat(rest); }
  if(/^\d+:\d+$/.test(str)){ const [mm, ss] = str.split(':'); return parseInt(mm)*60 + parseInt(ss); }
  if(/^(\d+|\d*\.\d+)$/.test(str)) return parseFloat(str);
  return audio.currentTime || 0;
}

document.getElementById('btnAddTap').addEventListener('click', ()=>{
  const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, parseInt(prompt(`Lane (1~${chart.lanes.length})`,'1'))-1 || 0));
  const t = parseTimeInput(prompt('Time (sec) or / for current','/'));
  const laneKeys = chart.lanes[laneIdx].keys || [];
  const keyChoice = laneKeys.length > 1 ? (prompt('Key for this tap', laneKeys[0]||'')||'') : (laneKeys[0]||'');
  const note = { id: nextId++, type:'tap', t, d:0, lane:laneIdx, keys: keyChoice ? keyChoice.split(/\s*\+\s*/).map(s=>s.toUpperCase()) : null, thickness: defaultThickness, colorIdx: getColorIdxForNote(laneIdx,t), customColor:null, hit:undefined, holdProgress:0, func:null };
  chart.lanes[laneIdx].notes.push(note); rebuildTimeline(); draw();
});

document.getElementById('btnAddHold').addEventListener('click', ()=>{
  const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, parseInt(prompt(`Lane (1~${chart.lanes.length})`,'1'))-1 || 0));
  const t = parseTimeInput(prompt('Start time (sec) or / for current','/'));
  const d = parseFloat(prompt('Duration (sec)','1.0')||'1.0');
  const laneKeys = chart.lanes[laneIdx].keys || [];
  const keyChoice = laneKeys.length === 1 ? laneKeys[0] : (prompt('Keys (use +) — leave empty to use lane defaults', laneKeys.join('+')||'')||'');
  const keys = keyChoice ? keyChoice.split(/\s*\+\s*/).map(s=>s.toUpperCase()) : null;
  const note = { id: nextId++, type:'hold', t, d, lane:laneIdx, keys, thickness: defaultThickness, colorIdx: getColorIdxForNote(laneIdx,t), customColor:null, hit:undefined, holdProgress:0, _holding:false, _holdStart:null, _initialLabel:null, func:null };
  chart.lanes[laneIdx].notes.push(note); rebuildTimeline(); draw();
});

document.getElementById('btnAddChord').addEventListener('click', ()=>{
  const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, parseInt(prompt(`Lane (1~${chart.lanes.length})`,'1'))-1 || 0));
  const t = parseTimeInput(prompt('Time (sec) or / for current','/'));
  const laneKeys = chart.lanes[laneIdx].keys || [];
  const defaultKeys = laneKeys.length >= 2 ? laneKeys[0] + '+' + laneKeys[1] : (laneKeys[0] ? laneKeys[0] + '+' + laneKeys[0] : 'A+B');
  const keys = prompt('Keys (A+B)', defaultKeys);
  const karr = keys ? keys.split(/\s*\+\s*/).map(s=>s.toUpperCase()) : (laneKeys.slice(0,2));
  const note = { id: nextId++, type:'chord', t, d:0, lane:laneIdx, keys: karr, thickness: defaultThickness, colorIdx:getColorIdxForNote(laneIdx,t), customColor:null, hit:undefined, holdProgress:0, func:null };
  chart.lanes[laneIdx].notes.push(note); rebuildTimeline(); draw();
});

document.getElementById('btnAddSeq').addEventListener('click', ()=>{
  const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, parseInt(prompt(`Lane (1~${chart.lanes.length})`,'1'))-1 || 0));
  const t = parseTimeInput(prompt('Time (sec) or / for current','/'));
  const keys = prompt('Sequence keys (A B) e.g. A B', 'A B') || 'A B';
  const karr = keys.split(/\s+|\+/).map(s=>s.toUpperCase());
  const d = parseFloat(prompt('Max gap (sec)','1.2')||'1.2');
  const note = { id: nextId++, type:'seq', t, d, lane:laneIdx, keys:karr, thickness: defaultThickness, colorIdx:getColorIdxForNote(laneIdx,t), customColor:null, hit:undefined, _seqAwaitingSecond:false, _seqFirstTime:null, _seqSecondCaptured:false, func:null };
  chart.lanes[laneIdx].notes.push(note); rebuildTimeline(); draw();
});

document.getElementById('btnAddFunc').addEventListener('click', ()=>{
  const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, parseInt(prompt(`Lane (1~${chart.lanes.length})`,'1'))-1 || 0));
  const t = parseTimeInput(prompt('Time (sec) or / for current','/'));
  const payload = prompt('Function payload JSON (e.g. {"keys":["A"],"speed":800})');
  let parsed = null;
  if(payload) try{ parsed = JSON.parse(payload); } catch(e){ alert('Invalid JSON'); return; }
  const note = { id: nextId++, type:'func', t, d:0, lane:laneIdx, keys:null, thickness: defaultThickness, colorIdx:getColorIdxForNote(laneIdx,t), customColor:'#ffd36b', hit:undefined, func: parsed };
  chart.lanes[laneIdx].notes.push(note); rebuildTimeline(); draw();
});

const inspectorForm = document.getElementById('inspectorForm');

function showInspector(note){
  document.getElementById('inspectorForm').style.display = 'block';
  document.getElementById('ins_type').value = note.type;
  document.getElementById('ins_time').value = (note.t||0).toFixed(3);
  document.getElementById('ins_d').value = (note.d||0).toFixed(3);
  document.getElementById('ins_keys').value = (note.keys||[]).join('+');
  document.getElementById('ins_th').value = note.thickness || defaultThickness;
  document.getElementById('ins_color').value = note.customColor || '';
  document.getElementById('ins_func').value = note.func ? JSON.stringify(note.func, null, 2) : '';
  selectedNote = note;
}

document.getElementById('ins_apply').addEventListener('click', ()=>{
  if(!selectedNote) return;
  selectedNote.type = document.getElementById('ins_type').value;
  selectedNote.t = parseFloat(document.getElementById('ins_time').value || 0);
  selectedNote.d = parseFloat(document.getElementById('ins_d').value || 0);
  const keys = document.getElementById('ins_keys').value.trim();
  selectedNote.keys = keys ? keys.split(/\s*\+\s*/).map(s=>s.toUpperCase()) : null;
  selectedNote.thickness = parseFloat(document.getElementById('ins_th').value || defaultThickness);
  const col = document.getElementById('ins_color').value.trim(); if(col) selectedNote.customColor = col; else delete selectedNote.customColor;
  const funcTxt = document.getElementById('ins_func').value.trim();
  if(selectedNote.type === 'func' && funcTxt){ try{ selectedNote.func = JSON.parse(funcTxt); } catch(e){ alert('Invalid func JSON'); return; } } else selectedNote.func = null;
  rebuildTimeline(); draw();
});

document.getElementById('ins_del').addEventListener('click', ()=>{
  if(!selectedNote) return;
  if(!confirm('Delete selected note?')) return;
  for(const lane of chart.lanes){
    const idx = lane.notes.findIndex(n=>n.id === selectedNote.id);
    if(idx >= 0){ lane.notes.splice(idx,1); selectedNote=null; document.getElementById('inspectorForm').style.display='none'; rebuildTimeline(); draw(); return; }
  }
});

const seek = document.getElementById('seek'), timeInput = document.getElementById('timeInput');

seek.addEventListener('input', ()=>{ seek._drag = true; const t = parseFloat(seek.value||0); timeInput.value = formatTime(t); updateTimelineCursor(); });

seek.addEventListener('change', ()=>{ seek._drag = false; if(mode==='play' && !audio.paused) { seek.value = audio.currentTime; return; } const newT = parseFloat(seek.value||0); handleSeekResetBehavior(newT); audio.currentTime = newT; updateTimelineCursor(); draw(); });

timeInput.addEventListener('change', ()=>{ const val = timeInput.value.trim(); const t = (val === '/') ? (audio.currentTime || 0) : parseTimeInput(val); if(mode==='play' && !audio.paused){ alert('Cannot seek in Play mode while playing'); return; } handleSeekResetBehavior(t); audio.currentTime = Math.max(0, Math.min(audio.duration || t, t)); updateTimelineCursor(); draw(); });

function updateTimelineCursor(){ const dur = audio.duration || Math.max(10, getLongestTime()+2); const curEl = document.getElementById('cursor'); if(curEl) curEl.style.left = (((audio.currentTime || 0) / dur) * 100) + '%'; if(!seek._drag) seek.value = (audio.currentTime || 0); if(document.activeElement !== timeInput) timeInput.value = formatTime(audio.currentTime || 0); }

function handleSeekResetBehavior(newT){
  if(mode === 'test'){
    for(const lane of chart.lanes) for(const note of lane.notes) {
      if(note.hit && note.t >= newT){
        delete note.hit; delete note._hitFade; delete note._holding; delete note._holdStart; note.holdProgress = 0;
        delete note._seqAwaitingSecond; delete note._seqFirstTime; delete note._seqSecondCaptured; delete note._initialLabel; delete note._autoFinalized;
      }
    }
  }
}

document.getElementById('startBtn').addEventListener('click', async ()=>{
  if(!audio.src){ alert('Please upload music first'); return; }
  mode = document.querySelector('input[name="mode"]:checked').value;
  if(mode === 'test'){
    try{ if(audio.paused) { audio.playbackRate = parseFloat(document.getElementById('playRate').value||1); await audio.play(); } else audio.pause(); }catch(err){ alert('Play failed: '+(err.message||err)); }
  } else {
    try{ audio.playbackRate = parseFloat(document.getElementById('playRate').value||1); await audio.play(); timeline.style.pointerEvents = 'none'; }catch(err){ alert('Play failed: '+(err.message||err)); }
  }
});

document.getElementById('pauseBtn').addEventListener('click', ()=>{ if(mode==='play' && !audio.paused){ alert('Cannot pause in Play mode'); return; } if(audio.paused) audio.play(); else audio.pause(); });
document.getElementById('resetBtn').addEventListener('click', ()=>{ audio.pause(); audio.currentTime = 0; timeline.style.pointerEvents = 'auto'; resetNotes(); rebuildTimeline(); draw(); });
document.getElementById('playRate').addEventListener('input', ()=>{ document.getElementById('rateVal').textContent = parseFloat(document.getElementById('playRate').value).toFixed(1) + 'x'; audio.playbackRate = parseFloat(document.getElementById('playRate').value); });

window.addEventListener('keydown', e=>{
  if(e.repeat) return;
  if(document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) return;
  const k = normalizeKey(e.key); if(!k) return;
  pressed.add(k);
  if(!lastPressTimes[k]) lastPressTimes[k] = [];
  lastPressTimes[k].push(audio.currentTime || 0); if(lastPressTimes[k].length>40) lastPressTimes[k].shift();
  handleSeqSecondKey(k);
  if(e.key === '/') { e.preventDefault(); quickAddTap(); return; }
  if(e.key === 'Delete' || e.key === 'Backspace'){ if(selectedNote && confirm('Delete selected note?')){ for(const lane of chart.lanes){ const idx = lane.notes.findIndex(n=>n.id===selectedNote.id); if(idx>=0){ lane.notes.splice(idx,1); selectedNote=null; document.getElementById('inspectorForm').style.display='none'; rebuildTimeline(); draw(); break; } } } return; }
  if(e.key === 'ArrowLeft' || e.key === 'ArrowRight'){
    e.preventDefault();
    const step = e.shiftKey ? 0.001 : 0.05; const dir = e.key === 'ArrowRight' ? 1 : -1;
    if(mode === 'play' && !audio.paused) return;
    const newT = Math.max(0, (audio.currentTime || 0) + dir*step); handleSeekResetBehavior(newT); audio.currentTime = newT; updateTimelineCursor(); draw();
  }
});

window.addEventListener('keyup', e=>{ const k = normalizeKey(e.key); if(!k) return; pressed.delete(k); processHoldReleases(k); });

function judgeDelta(ms){
  const a = Math.abs(ms);
  if(a <= judgew.w) return 'wonderful';
  if(a <= judgew.pp) return 'perfect+';
  if(a <= judgew.p) return 'perfect';
  if(a <= judgew.g) return 'good';
  return 'miss';
}
function labelToBaseScore(label){ return BASE_SCORES[label] || 0; }

function finalizeHit(note, label, scoreAddRaw){
  if(note.hit) return;
  note.hit = label;
  note._hitFade = true;
  showLaneJudge(note.lane, label);
  let base = (typeof scoreAddRaw === 'number') ? scoreAddRaw : labelToBaseScore(label);
  if(note._holdMultiplier) base = Math.round(base * note._holdMultiplier);
  let added = base * mult;
  if(mode === 'play') added = Math.floor(added / 10);
  score += added;
  applyChargeOnHit(label);
  document.getElementById('lastHit').textContent = label + ' *' + mult;
  updateHUD();
  setTimeout(()=>{ if(document.getElementById('lastHit').textContent.startsWith(label)) document.getElementById('lastHit').textContent = '—'; },700);
}

function showLaneJudge(laneIndex, label){
  const el = document.createElement('div'); el.className='bigJudge';
  el.style.top = (layout.y + 40) + 'px';
  el.style.fontSize = '28px';
  el.style.color = (label === 'miss') ? 'var(--bad)' : ((label === 'good') ? 'var(--warn)' : 'var(--good)');
  el.textContent = label.toUpperCase();
  document.body.appendChild(el);
  setTimeout(()=> el.remove(), 700);
}

function applyChargeOnHit(label){
  if(label === 'miss'){ multBucket = 0; mult = 1; combo = 0; streak = 0; updateMultProgress(); return; }
  let pts = 0;
  if(label === 'perfect') pts = 1;
  else if(label === 'perfect+') pts = 2;
  else if(label === 'wonderful') pts = 4;
  else if(label === 'good'){ multBucket = Math.max(0, multBucket - 5); if(multBucket < (mult-1)*MULT_THRESHOLD) mult = Math.max(1, Math.floor(multBucket / MULT_THRESHOLD) + 1); updateMultProgress(); return; }
  if(mult >= 3){
    if(label === 'perfect') pts = 0;
    if(label === 'perfect+') pts = 1;
    if(label === 'wonderful') pts = 2;
  }
  multBucket = Math.min(9999, multBucket + pts);
  const newMult = Math.min(MULT_MAX, Math.floor(multBucket / MULT_THRESHOLD) + 1);
  mult = Math.max(mult, newMult);
  updateMultProgress();
}
function updateMultProgress(){ const levelBase = (multBucket % MULT_THRESHOLD); const pct = Math.min(100, (levelBase / MULT_THRESHOLD) * 100); document.getElementById('multProgress').style.width = pct + '%'; document.getElementById('mult').textContent = mult; document.getElementById('topMult').textContent = mult; }

function tickJudge(){
  const t = audio.currentTime || 0;
  for(let laneIndex=0; laneIndex<chart.lanes.length; laneIndex++){
    const lane = chart.lanes[laneIndex]; const effLane = effectiveLaneAt(laneIndex, t);
    for(const note of lane.notes){
      if(note.hit) continue;
      if(note.type === 'func') continue;
      const ms = (t - note.t) * 1000;
      if(ms > judgew.late){ finalizeHit(note, 'miss'); continue; }
      if(Math.abs(ms) <= judgew.g){
        if(note.type === 'tap'){
          const req = note.keys && note.keys.length ? note.keys : (effLane.keys || lane.keys || []);
          const ok = req.length === 0 ? false : req.some(k => pressed.has(k));
          if(ok) finalizeHit(note, judgeDelta(ms));
        } else if(note.type === 'chord'){
          const effKeys = effLane.keys || lane.keys || [];
          if(effKeys.length === 1 && (!note.d || note.d === 0)){
            const dist = Math.abs((layout.y + layout.h*0.9) - (layout.y + layout.h*0.9 - (note.t - t)* (effLane.speed||lane.speed||600)));
            if(dist <= 6 && effKeys.length === 1 && !note.hit){
              const k = effKeys[0];
              if(pressed.has(k)){ const baseVal = 10; let added = Math.round(baseVal * 0.5 * mult); if(mode==='play') added = Math.floor(added/10); score += added; note.hit='chord50'; document.getElementById('lastHit').textContent='Chord50 *'+mult; updateHUD(); }
            }
          } else {
            const req = note.keys && note.keys.length ? note.keys : (lane.keys || effLane.keys || []);
            const all = req.length>0 ? req.every(k => pressed.has(k)) : false;
            if(all) finalizeHit(note, judgeDelta(ms));
            else if(req.some(k => pressed.has(k))) finalizeHit(note, 'good');
          }
        } else if(note.type === 'seq'){
          const req = note.keys && note.keys.length ? note.keys : (effLane.keys||lane.keys||[]);
          const first = req[0];
          const second = req[1] || first;
          if(!note._seqCapturedFirst && first && pressed.has(first)){
            const label = judgeDelta(ms);
            finalizeHit(note, label);
            note._seqCapturedFirst = true;
            note._seqFirstTime = audio.currentTime || 0;
            note._seqAwaitingSecond = true;
          } else if(note._seqAwaitingSecond){
            if((audio.currentTime || 0) - (note._seqFirstTime || 0) > (note.d || 1.2)){
              note._seqAwaitingSecond = false;
              // spec: if second key not pressed in window -> treat as miss
              finalizeHit(note, 'miss');
            }
          }
        } else if(note.type === 'hold'){
          const eff = effLane;
          const req = note.keys && note.keys.length ? note.keys : (eff.keys || lane.keys || []);
          const requiredAll = req.length === 0 ? false : req.every(k => pressed.has(k));
          if(requiredAll && !note._holding){
            note._holding = true;
            note._holdStart = Math.max(audio.currentTime || 0, note.t);
            const pressDeltaMs = (note._holdStart - note.t) * 1000;
            const label = judgeDelta(pressDeltaMs);
            note._initialLabel = label;
            // award immediate base of initial label (as spec requires judge on press)
            // We defer final hold bonus until release or auto-end
            finalizeHit(note, label); // award base now
            // but mark so we won't finalize again incorrectly at end
            note._holdGivenBase = true;
          }
          if(note._holding){
            const now = audio.currentTime || 0;
            const held = Math.max(0, Math.min(now, note.t + (note.d || 1)) - note._holdStart);
            note.holdProgress = Math.max(0, Math.min(1, held / (note.d || 1)));
            // if reached end while still holding and not yet given full bonus:
            if(now >= note.t + (note.d || 1) && !note._autoFinalized){
              note._autoFinalized = true;
              const baseLabel = note._initialLabel || judgeDelta((note._holdStart - note.t)*1000);
              const baseVal = labelToBaseScore(baseLabel);
              const totalHoldBonus = Math.round(baseVal * 0.5 * mult);
              // award the hold bonus now (full)
              score += totalHoldBonus;
              updateHUD();
              note._holdFinalLabel = baseLabel; note._holdRatio = 1.0; note._holdMultiplier = 1.5;
              // mark as finalized visually
              note._holding = false;
            }
          }
        }
      }
    }
  }
}

function processHoldReleases(kReleased){
  const now = audio.currentTime || 0;
  for(const lane of chart.lanes){
    for(const note of lane.notes){
      if(note.type === 'hold' && note._holding && !note.hit){
        const eff = effectiveLaneAt(note.lane, now);
        const req = note.keys && note.keys.length ? note.keys : (eff.keys || chart.lanes[note.lane].keys || []);
        const stillAll = req.length === 0 ? false : req.every(k => pressed.has(k));
        if(!stillAll){
          const holdStart = note._holdStart || note.t;
          const held = Math.max(0, Math.min(now, note.t + (note.d || 1)) - holdStart);
          const ratio = Math.min(1, held / (note.d || 1));
          note.holdProgress = ratio;
          const baseLabel = note._initialLabel || judgeDelta((holdStart - note.t) * 1000);
          note._holdFinalLabel = baseLabel; note._holdRatio = ratio; note._holdMultiplier = 1.5;
          if(ratio <= 0.01) finalizeHit(note, 'miss');
          else {
            const baseVal = labelToBaseScore(baseLabel);
            const computed = Math.round(baseVal * ratio * 1.5);
            finalizeHit(note, baseLabel, computed);
          }
          note._holding = false; note._holdStart = null;
        }
      }
    }
  }
}

function handleSeqSecondKey(k){
  const now = audio.currentTime || 0;
  for(const lane of chart.lanes){
    for(const note of lane.notes){
      if(note.type === 'seq' && note._seqAwaitingSecond && !note._seqSecondCaptured){
        const firstTime = note._seqFirstTime || 0;
        if(now - firstTime <= (note.d || 1.2)){
          const k2 = note.keys && note.keys[1] ? note.keys[1] : null;
          if(k2 && k === k2){
            const baseLabel = note.hit || note._initialLabel || 'good';
            const baseVal = labelToBaseScore(baseLabel);
            const extra = Math.round(baseVal * 0.20);
            let added = extra * mult; if(mode === 'play') added = Math.floor(added / 10);
            score += added; note._seqSecondCaptured = true; updateHUD();
          }
        }
      }
    }
  }
}

function updateHUD(){ document.getElementById('score').textContent = score; document.getElementById('combo').textContent = combo; document.getElementById('mult').textContent = mult; document.getElementById('topMult').textContent = mult; }

function effectiveLaneAt(laneIndex, t){
  const lane = chart.lanes[laneIndex] || { keys:[], speed:600, notes:[] };
  let eff = { keys: lane.keys, speed: lane.speed };
  const funcs = (lane.notes||[]).filter(n=> n.type==='func' && n.t <= t).sort((a,b)=>a.t-b.t);
  if(funcs.length > 0){
    const last = funcs[funcs.length-1];
    if(last.func){ eff.keys = last.func.keys || eff.keys; eff.speed = last.func.speed || eff.speed; }
  }
  return eff;
}
function effectiveLaneKeysAt(laneIndex,t){ return effectiveLaneAt(laneIndex,t).keys; }
function effectiveLaneSpeedAt(laneIndex,t){ return effectiveLaneAt(laneIndex,t).speed; }

function draw(){
  const w = canvas.clientWidth, h = canvas.clientHeight;
  ctx.clearRect(0,0,w,h);
  ctx.fillStyle = '#03122a'; ctx.fillRect(layout.x, layout.y, layout.w, layout.h);

  const laneCount = chart.lanes.length;
  const laneW = (layout.w - (laneCount+1)*8) / laneCount;
  for(let i=0;i<laneCount;i++){
    const x = layout.x + 8 + i*(laneW+8);
    ctx.fillStyle = '#081630'; ctx.fillRect(x, layout.y+8, laneW, layout.h-16);
    ctx.fillStyle = '#7ea8ff'; ctx.font = '12px system-ui';
    ctx.fillText(`Lane ${i+1} [${(chart.lanes[i].keys||[]).join('+')}]`, x+6, layout.y+22);
    ctx.strokeStyle = '#2f8be0'; ctx.setLineDash([6,4]); ctx.beginPath();
    ctx.moveTo(x, layout.y + layout.h*0.9); ctx.lineTo(x + laneW, layout.y + layout.h*0.9); ctx.stroke(); ctx.setLineDash([]);
  }

  const tNow = audio.currentTime || 0;

  for(let li=0; li<chart.lanes.length; li++){
    const lane = chart.lanes[li];
    const x = layout.x + 8 + li*(laneW+8);
    const effSpeed = effectiveLaneSpeedAt(li, tNow) || lane.speed || 600;

    for(const note of lane.notes){
      if(note._hidden) continue;
      const dt = note.t - tNow;
      const yJudge = layout.y + layout.h*0.9;
      // consistent mapping: when note.t == tNow => y == judge line
      const y = yJudge - dt * effSpeed;
      if(y < layout.y - 200 || y > layout.y + layout.h + 200) continue;
      const thickness = note.thickness || defaultThickness;
      const height = Math.max(6, baseHalf * (thickness/6));
      const color = note.customColor || COLORS[(note.colorIdx||0) % COLORS.length];
      const alpha = note._hitFade ? 0.35 : 1.0;
      const ms = (tNow - note.t) * 1000;
      const absms = Math.abs(ms);
      let canPerfectPlus = absms <= judgew.pp;
      let canWonderful = absms <= judgew.w;
      let canGood = absms <= judgew.g;
      let outline = null;
      if(!note.hit){
        if(canWonderful || canPerfectPlus) outline = 'white';
        else if(canGood) outline = 'orange';
      }

      if(note.type === 'func'){
        ctx.fillStyle = hexToRgba('#ffd36b', 0.85);
        roundRect(ctx, x+6, y - height/2, laneW-12, height, 8); ctx.fill();
        ctx.fillStyle = '#031029'; ctx.font = '12px system-ui'; ctx.fillText('FUNC', x+12, y + 4);
        continue;
      }

      if(note.type === 'tap' || note.type === 'seq'){
        ctx.fillStyle = hexToRgba(color, alpha);
        const ry = y - height/2;
        roundRect(ctx, x+6, ry, laneW-12, height, 10); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.25)'; ctx.stroke();
        if(outline){ ctx.lineWidth = 3; ctx.strokeStyle = outline === 'white' ? '#fff' : '#ff9b3b'; ctx.strokeRect(x+6, ry, laneW-12, height); ctx.lineWidth = 1; }
        ctx.fillStyle = '#031029'; ctx.font = 'bold 13px system-ui';
        const label = note.type === 'tap' ? (note.keys ? note.keys.join('+') : 'Tap') : (note.keys||[]).join('→');
        ctx.fillText(label, x+12, ry + height/2 + 5);
      } else if(note.type === 'chord'){
        const effKeys = effectiveLaneKeysAt(li, tNow) || lane.keys || [];
        if(effKeys.length === 1 && (!note.d || note.d===0)){
          const ry = y - height/6;
          const lineLen = Math.max(60, effSpeed*0.25);
          ctx.fillStyle = hexToRgba(color, alpha);
          ctx.fillRect(x+12 - lineLen/2, ry, laneW-12 + lineLen, Math.max(8, height/3));
          if(outline){ ctx.lineWidth=3; ctx.strokeStyle = outline==='white'?'#fff':'#ff9b3b'; ctx.strokeRect(x+12 - lineLen/2, ry, laneW-12 + lineLen, Math.max(8, height/3)); ctx.lineWidth=1; }
          ctx.fillStyle = '#031029'; ctx.font='bold 13px system-ui'; ctx.fillText((note.keys||[]).join('+'), x+12, ry + 10);
          const dist = Math.abs(y - yJudge);
          if(dist <= 6 && effKeys.length === 1 && !note.hit){
            const k = effKeys[0];
            if(pressed.has(k)){
              const baseVal = 10; let added = Math.round(baseVal * 0.5 * mult); if(mode==='play') added = Math.floor(added/10); score += added; note.hit='chord50'; document.getElementById('lastHit').textContent='Chord50 *'+mult; updateHUD();
            }
          }
        } else {
          ctx.fillStyle = hexToRgba(color, alpha);
          const ry = y - height/2;
          roundRect(ctx, x+6, ry, laneW-12, height, 10); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.25)'; ctx.stroke();
          if(outline){ ctx.lineWidth=3; ctx.strokeStyle = outline==='white'?'#fff':'#ff9b3b'; ctx.strokeRect(x+6, ry, laneW-12, height); ctx.lineWidth=1; }
          ctx.fillStyle = '#031029'; ctx.font='bold 13px system-ui'; ctx.fillText((note.keys||[]).join('+'), x+12, ry + height/2 + 5);
        }
      } else if(note.type === 'hold'){
        const yStart = y;
        const effSpeedEnd = effectiveLaneSpeedAt(li, note.t + (note.d||0)) || lane.speed || 600;
        const yEnd = (layout.y + layout.h*0.9) - ((note.t + (note.d||0)) - tNow) * effSpeedEnd;
        const top = Math.min(yStart,yEnd), bottom = Math.max(yStart,yEnd);
        const thicknessPx = Math.max(20, Math.abs(bottom-top)/6);
        let holdAlpha = alpha;
        if(note._holding){ const p = Math.max(0, Math.min(1, note.holdProgress || 0)); holdAlpha = 1 - 0.5 * p; } else if(note.holdProgress && note.holdProgress>0) holdAlpha = 0.6;
        ctx.fillStyle = hexToRgba(color, holdAlpha);
        roundRect(ctx, x+10, top - thicknessPx/2, laneW-20, (bottom-top) + thicknessPx, 10); ctx.fill(); ctx.strokeStyle='rgba(0,0,0,0.25)'; ctx.stroke();
        if(note.holdProgress && note.holdProgress>0){ ctx.fillStyle='rgba(255,255,255,0.12)'; const fullH = (bottom-top) + thicknessPx; ctx.fillRect(x+10, top - thicknessPx/2, laneW-20, fullH * Math.max(0, Math.min(1, note.holdProgress))); }
        if(outline){
          const init = note._initialLabel || null;
          if(note._holding){
            if(init === 'perfect' || init === 'perfect+' || init === 'wonderful'){ ctx.lineWidth=3; ctx.strokeStyle='#fff'; ctx.strokeRect(x+10, top - thicknessPx/2, laneW-20, (bottom-top) + thicknessPx); ctx.lineWidth=1; }
            else if(init === 'good'){ ctx.lineWidth=3; ctx.strokeStyle='#ff9b3b'; ctx.strokeRect(x+10, top - thicknessPx/2, laneW-20, (bottom-top) + thicknessPx); ctx.lineWidth=1; }
          } else {
            ctx.lineWidth=3; ctx.strokeStyle = outline==='white'?'#fff':'#ff9b3b'; ctx.strokeRect(x+10, top - thicknessPx/2, laneW-20, (bottom-top) + thicknessPx); ctx.lineWidth=1;
          }
        }
      }
    }
  }

  document.getElementById('score').textContent = score;
  document.getElementById('combo').textContent = combo;
  document.getElementById('mult').textContent = mult;
  document.getElementById('timeDisp').textContent = formatTime(audio.currentTime || 0);
}

function roundRect(ctx,x,y,w,h,r){ const rr=Math.min(r,w/2,h/2); ctx.beginPath(); ctx.moveTo(x+rr,y); ctx.arcTo(x+w,y,x+w,y+h,rr); ctx.arcTo(x+w,y+h,x,y+h,rr); ctx.arcTo(x,y+h,x,y,rr); ctx.arcTo(x,y,x+w,y,rr); ctx.closePath(); }

audio.addEventListener('timeupdate', ()=>{ tickJudge(); updateTimelineCursor(); });
audio.addEventListener('seeked', ()=>{ updateTimelineCursor(); draw(); });
audio.addEventListener('loadedmetadata', ()=>{ seek.max = audio.duration || 0; rebuildTimeline(); draw(); });
audio.addEventListener('ended', ()=>{ if(mode==='play') timeline.style.pointerEvents = 'auto'; });

function rafLoop(){ draw(); requestAnimationFrame(rafLoop); }
rafLoop();

rebuildTimeline();

function pickNoteAtXY(cx,cy){
  const tNow = audio.currentTime || 0;
  const laneCount = chart.lanes.length;
  const laneW = (layout.w - (laneCount+1)*8) / laneCount;
  for(let i=0;i<laneCount;i++){
    const lane = chart.lanes[i];
    const x = layout.x + 8 + i*(laneW+8);
    for(const note of lane.notes){
      const effSpeed = effectiveLaneSpeedAt(i, tNow) || lane.speed || 600;
      const dt = note.t - tNow;
      const yJudge = layout.y + layout.h*0.9;
      const y = yJudge - dt * effSpeed;
      const thickness = note.thickness || defaultThickness;
      const height = baseHalf * (thickness/6);
      if(note.type === 'tap' || note.type === 'chord' || note.type === 'seq' || note.type === 'func'){
        const rx = x+6, ry = y - height/2, rw = laneW-12, rh = height;
        if(cx>=rx && cx<=rx+rw && cy>=ry && cy<=ry+rh) return note;
      } else if(note.type === 'hold'){
        const yStart = y;
        const yEnd = (layout.y + layout.h*0.9) - ((note.t + (note.d||0)) - tNow) * (effectiveLaneSpeedAt(i, note.t + (note.d||0)) || lane.speed || 600);
        const top = Math.min(yStart,yEnd), bottom = Math.max(yStart,yEnd);
        const rx = x+10, ry = top - 12, rw = laneW-20, rh = (bottom-top)+24;
        if(cx>=rx && cx<=rx+rw && cy>=ry && cy<=ry+rh) return note;
      }
    }
  }
  return null;
}

canvas.addEventListener('click', e=>{
  const r = canvas.getBoundingClientRect();
  const cx = e.clientX - r.left, cy = e.clientY - r.top;
  const n = pickNoteAtXY(cx,cy);
  if(n){ selectNoteById(n.id); showInspector(n); } else { selectedNote=null; document.getElementById('inspectorForm').style.display='none'; }
});

function quickAddTap(){
  const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, parseInt(prompt(`Lane (1~${chart.lanes.length})`,'1'))-1 || 0));
  const t = audio.currentTime || 0;
  const laneKeys = chart.lanes[laneIdx].keys || [];
  const keyChoice = laneKeys.length > 1 ? (prompt('Key for this tap', laneKeys[0]||'')||'') : (laneKeys[0]||'');
  const note = { id: nextId++, type:'tap', t, d:0, lane:laneIdx, keys: keyChoice ? keyChoice.split(/\s*\+\s*/).map(s=>s.toUpperCase()) : null, thickness: defaultThickness, colorIdx:getColorIdxForNote(laneIdx,t), customColor:null, hit:undefined, holdProgress:0, func:null };
  chart.lanes[laneIdx].notes.push(note); rebuildTimeline(); draw();
}

function getColorIdxForNote(laneIdx,t){ const same = (chart.lanes[laneIdx].notes||[]).filter(n=>Math.abs(n.t - t) < 1e-6).length; return same % COLORS.length; }

function resetNotes(){
  for(const lane of chart.lanes) for(const n of lane.notes){ delete n.hit; delete n._hitFade; delete n._holding; delete n._holdStart; n.holdProgress=0; delete n._seqAwaitingSecond; delete n._seqFirstTime; delete n._seqSecondCaptured; delete n._initialLabel; delete n._autoFinalized; }
  score=0; combo=0; mult=1; multBucket=0; streak=0; updateMultProgress(); updateHUD();
}

function selectNoteById(id){
  selectedNote = null;
  for(const lane of chart.lanes) for(const n of lane.notes) if(n.id === id) selectedNote = n;
  if(selectedNote) showInspector(selectedNote);
}

function updateHUD(){ document.getElementById('score').textContent = score; document.getElementById('combo').textContent = combo; document.getElementById('mult').textContent = mult; document.getElementById('topMult').textContent = mult; }

document.getElementById('laneCount').addEventListener('change', e=>{ const n=Math.max(1,Math.min(8,parseInt(e.target.value||4))); ensureLanes(n); rebuildLaneSpeedUI(); rebuildTimeline(); draw(); });
document.getElementById('globalThickness').addEventListener('change', e=>{ defaultThickness = parseFloat(e.target.value||6); });
document.getElementById('baseHalf').addEventListener('change', e=>{ baseHalf = parseFloat(e.target.value||12); draw(); });
['w','pp','p','g','late'].forEach(id=>document.getElementById(id).addEventListener('change', ()=>{ judgew.w = +document.getElementById('w').value; judgew.pp = +document.getElementById('pp').value; judgew.p = +document.getElementById('p').value; judgew.g = +document.getElementById('g').value; judgew.late = +document.getElementById('late').value; }));

document.getElementById('exportBtn').addEventListener('click', ()=>{ const out = JSON.stringify(chart, null, 2); const blob = new Blob([out], { type:'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (chart.title||'chart')+'.json'; a.click(); });

function updateTimelineCursor(){ const dur = audio.duration || Math.max(10, getLongestTime()+2); const cur = document.getElementById('cursor'); if(cur) cur.style.left = (((audio.currentTime || 0) / dur) * 100) + '%'; if(!seek._drag) seek.value = (audio.currentTime || 0); if(document.activeElement !== timeInput) timeInput.value = formatTime(audio.currentTime || 0); }

function rebuildTimeline(){ timeline.innerHTML = ''; const dur = audio.duration || Math.max(10, getLongestTime()+2); for(let s=0;s<Math.ceil(dur);s+=1){ const v = document.createElement('div'); v.style.position='absolute'; v.style.left = (s/dur*100)+'%'; v.style.top='0'; v.style.bottom='0'; v.style.width='1px'; v.style.background = (s%5===0) ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.01)'; timeline.appendChild(v); } chart.lanes.forEach((lane,i)=>{ (lane.notes||[]).forEach(note=>{ const chip = document.createElement('div'); chip.className='note-chip'; chip.dataset.id = note.id; chip.style.top = (6 + i*22) + 'px'; const left = ((note.t / (dur || 1)) * 100); chip.style.left = left + '%'; chip.textContent = note.type === 'tap' ? (note.keys ? note.keys.join('+') : 'Tap') : (note.type==='hold' ? 'Hold' : (note.keys ? ((note.type==='seq')?note.keys.join('→'):note.keys.join('+')) : note.type)); chip.style.background = note.customColor || COLORS[(note.colorIdx||0) % COLORS.length]; chip.addEventListener('click', ev => { ev.stopPropagation(); selectNoteById(note.id); showInspector(note); }); timeline.appendChild(chip); }); }); const cur = document.createElement('div'); cur.id='cursor'; cur.style.position='absolute'; cur.style.top='0'; cur.style.bottom='0'; cur.style.width='2px'; cur.style.background='#6ad1ff'; timeline.appendChild(cur); updateTimelineCursor(); }

function pickNoteAtXY(cx,cy){
  const tNow = audio.currentTime || 0;
  const laneCount = chart.lanes.length;
  const laneW = (layout.w - (laneCount+1)*8) / laneCount;
  for(let i=0;i<laneCount;i++){
    const lane = chart.lanes[i];
    const x = layout.x + 8 + i*(laneW+8);
    for(const note of lane.notes){
      const effSpeed = effectiveLaneSpeedAt(i, tNow) || lane.speed || 600;
      const dt = note.t - tNow;
      const yJudge = layout.y + layout.h*0.9;
      const y = yJudge - dt * effSpeed;
      const thickness = note.thickness || defaultThickness;
      const height = baseHalf * (thickness/6);
      if(note.type === 'tap' || note.type === 'chord' || note.type === 'seq' || note.type === 'func'){
        const rx = x+6, ry = y - height/2, rw = laneW-12, rh = height;
        if(cx>=rx && cx<=rx+rw && cy>=ry && cy<=ry+rh) return note;
      } else if(note.type === 'hold'){
        const yStart = y;
        const yEnd = (layout.y + layout.h*0.9) - ((note.t + (note.d||0)) - tNow) * (effectiveLaneSpeedAt(i, note.t + (note.d||0)) || lane.speed || 600);
        const top = Math.min(yStart,yEnd), bottom = Math.max(yStart,yEnd);
        const rx = x+10, ry = top - 12, rw = laneW-20, rh = (bottom-top)+24;
        if(cx>=rx && cx<=rx+rw && cy>=ry && cy<=ry+rh) return note;
      }
    }
  }
  return null;
}

document.getElementById('audiodummy');

function selectNoteById(id){
  selectedNote = null;
  for(const lane of chart.lanes) for(const n of lane.notes) if(n.id === id) selectedNote = n;
  if(selectedNote) showInspector(selectedNote);
}

function getColorIdxForNote(laneIdx,t){ const same = (chart.lanes[laneIdx].notes||[]).filter(n=>Math.abs(n.t - t) < 1e-6).length; return same % COLORS.length; }

function resetNotes(){
  for(const lane of chart.lanes) for(const n of lane.notes){ delete n.hit; delete n._hitFade; delete n._holding; delete n._holdStart; n.holdProgress=0; delete n._seqAwaitingSecond; delete n._seqFirstTime; delete n._seqSecondCaptured; delete n._initialLabel; delete n._autoFinalized; }
  score=0; combo=0; mult=1; multBucket=0; streak=0; updateMultProgress(); updateHUD();
}

function updateHUD(){ document.getElementById('score').textContent = score; document.getElementById('combo').textContent = combo; document.getElementById('mult').textContent = mult; document.getElementById('topMult').textContent = mult; }

document.getElementById('resetBtn').addEventListener('click', ()=>{ audio.pause(); audio.currentTime = 0; timeline.style.pointerEvents = 'auto'; resetNotes(); rebuildTimeline(); draw(); });

document.getElementById('chartFile').addEventListener('change', async e=>{ /* handled earlier */ });

/* Expose some console helpers for debugging */
window._chart = chart;
window._redraw = ()=>{ rebuildTimeline(); draw(); };

console.log('RhythmStudio v11 fixed loaded. Reference input: uploaded v11 docx.  [oai_citation:2?!doctype html 11.docx](file-service://file-H6JS7McHneTwt9NaWHUJ2G)');

</script>
</body>
</html>