<!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>