mroctopus / app /src /components /BassTab.jsx
Ewan
Add piano arrangement engine + guitar/bass tab views for Full Song mode
1a538e9
import { useEffect, useRef, useCallback } from 'react';
import { COLORS } from '../utils/colorScheme';
const LOOK_AHEAD = 4; // seconds ahead to show
const STRING_LABELS = ['G', 'D', 'A', 'E']; // high to low (display order)
export default function BassTab({
tabData,
currentTimeRef,
width,
height,
}) {
const canvasRef = useRef(null);
const animRef = useRef(null);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || !tabData) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
}
const currentTime = currentTimeRef?.current ?? 0;
const numStrings = tabData.strings || 4;
// Layout
const topMargin = 50;
const bottomMargin = 30;
const leftMargin = 40;
const rightMargin = 20;
const stringAreaHeight = height - topMargin - bottomMargin;
const stringSpacing = stringAreaHeight / (numStrings - 1);
const playableWidth = width - leftMargin - rightMargin;
// Progress line at 20% from left
const progressX = leftMargin + playableWidth * 0.2;
const pixelsPerSecond = (playableWidth * 0.8) / LOOK_AHEAD;
// Clear
ctx.fillStyle = COLORS.tabBg;
ctx.fillRect(0, 0, width, height);
// Draw string labels
ctx.font = '13px Inter, monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i < numStrings; i++) {
const y = topMargin + i * stringSpacing;
ctx.fillStyle = COLORS.textMuted;
// Display high string (index 3) at top, low string (index 0) at bottom
const labelIdx = numStrings - 1 - i;
ctx.fillText(STRING_LABELS[labelIdx] || '', leftMargin - 10, y);
}
// Draw string lines
ctx.strokeStyle = COLORS.tabString;
ctx.lineWidth = 1;
for (let i = 0; i < numStrings; i++) {
const y = topMargin + i * stringSpacing;
ctx.beginPath();
ctx.moveTo(leftMargin, y);
ctx.lineTo(width - rightMargin, y);
ctx.stroke();
}
// Draw fret numbers
const events = tabData.events || [];
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = 'bold 14px Inter, monospace';
for (const event of events) {
const x = progressX + (event.time - currentTime) * pixelsPerSecond;
if (x < leftMargin - 30 || x > width + 30) continue;
const isPast = event.time <= currentTime;
const isActive = isPast && event.time + event.duration > currentTime;
for (let s = 0; s < numStrings; s++) {
const fret = event.frets[s];
if (fret === null || fret === undefined) continue;
// String display: index 0 (low E) at bottom, index 3 (G) at top
const displayRow = numStrings - 1 - s;
const y = topMargin + displayRow * stringSpacing;
const textWidth = ctx.measureText(String(fret)).width;
const pillW = Math.max(textWidth + 8, 18);
const pillH = 18;
if (isActive) {
ctx.fillStyle = 'rgba(139, 92, 246, 0.3)';
ctx.beginPath();
ctx.roundRect(x - pillW / 2, y - pillH / 2, pillW, pillH, 4);
ctx.fill();
}
// Clear string line behind the number
ctx.fillStyle = COLORS.tabBg;
ctx.fillRect(x - pillW / 2, y - pillH / 2, pillW, pillH);
// Fret number
ctx.fillStyle = isPast ? COLORS.tabFretPlayed : COLORS.tabFret;
ctx.fillText(String(fret), x, y);
}
}
// Progress line
ctx.strokeStyle = COLORS.tabProgressLine;
ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(139, 92, 246, 0.5)';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.moveTo(progressX, topMargin - 10);
ctx.lineTo(progressX, topMargin + (numStrings - 1) * stringSpacing + 10);
ctx.stroke();
ctx.shadowBlur = 0;
// "TAB" clef
ctx.fillStyle = COLORS.textMuted;
ctx.font = 'bold 16px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const tabY = topMargin + stringAreaHeight / 2;
ctx.fillText('T', 15, tabY - 12);
ctx.fillText('A', 15, tabY);
ctx.fillText('B', 15, tabY + 12);
animRef.current = requestAnimationFrame(draw);
}, [tabData, currentTimeRef, width, height]);
useEffect(() => {
animRef.current = requestAnimationFrame(draw);
return () => {
if (animRef.current) cancelAnimationFrame(animRef.current);
};
}, [draw]);
if (!tabData) {
return (
<div className="tab-empty">
<p>No bass tab data available.</p>
</div>
);
}
return <canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />;
}