Initial commit
This commit is contained in:
@@ -0,0 +1,785 @@
|
||||
.app-shell {
|
||||
padding: 24px;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 6px 0 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--button-ghost-bg);
|
||||
color: var(--button-ghost-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-toggle:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.audio-bar {
|
||||
margin-top: 14px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.audio-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.audio-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.audio-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.volume-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.volume-value {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 18px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow-panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 360px;
|
||||
max-width: 360px;
|
||||
/* Use absolute positioning to enforce height */
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
background: var(--panel-soft);
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.controls input[type='number'] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.timesig {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.timesig span {
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.transport {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.selection-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.selection-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 6px 10px !important;
|
||||
}
|
||||
|
||||
.selection-btn.active {
|
||||
background: var(--accent) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.button,
|
||||
.actions button,
|
||||
.transport button,
|
||||
.ghost,
|
||||
.primary,
|
||||
.soft {
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease, border 140ms ease;
|
||||
color: #0f1528;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: var(--button-ghost-bg);
|
||||
color: var(--button-ghost-text);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: var(--button-primary-text);
|
||||
box-shadow: 0 8px 26px rgba(72, 228, 194, 0.2);
|
||||
}
|
||||
|
||||
.soft {
|
||||
background: var(--button-soft-bg);
|
||||
color: var(--button-soft-text);
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.ghost:disabled,
|
||||
.primary:disabled,
|
||||
.soft:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ghost:hover,
|
||||
.primary:hover,
|
||||
.soft:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.piano-shell {
|
||||
border-radius: 12px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ruler {
|
||||
position: relative;
|
||||
height: 32px;
|
||||
background: var(--panel-soft);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.ruler-shell {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ruler-spacer {
|
||||
background: var(--panel-soft);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.ruler-scroll {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.measure-mark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding-left: 4px;
|
||||
border-left: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.measure-mark span {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ruler-playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: #ff7043;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.ruler-scroll.selecting {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selection-range {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: rgba(66, 165, 245, 0.35);
|
||||
border-left: 2px solid #42a5f5;
|
||||
border-right: 2px solid #42a5f5;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.grid-selection-range {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
background: rgba(66, 165, 245, 0.15);
|
||||
border-left: 2px dashed #42a5f5;
|
||||
border-right: 2px dashed #42a5f5;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.roll-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pitch-rail {
|
||||
background: var(--panel-strong);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pitch-cell {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pitch-white {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pitch-black {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(233, 238, 247, 0.9);
|
||||
}
|
||||
|
||||
.pitch-c {
|
||||
background: rgba(100, 150, 255, 0.15);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pitch-label {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.roll-grid {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background-color: var(--grid-bg);
|
||||
}
|
||||
|
||||
.grid-content {
|
||||
background-color: var(--grid-bg);
|
||||
}
|
||||
|
||||
.grid-svg {
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-chip {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--note-text);
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.note-active {
|
||||
outline: 2px solid #ff7043;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.note-overlap {
|
||||
background: linear-gradient(135deg, #ef5350 0%, #ff7043 100%) !important;
|
||||
animation: pulse-overlap 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Selected overlapping note - more visible outline */
|
||||
.note-overlap.note-active {
|
||||
outline: 3px solid #1e40af;
|
||||
outline-offset: 1px;
|
||||
box-shadow: 0 0 12px rgba(30, 64, 175, 0.8);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse-overlap {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
background: #ff7043;
|
||||
box-shadow: 0 0 12px rgba(255, 112, 67, 0.6);
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.pitch-rail-inner {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.note-label {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 8px;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.note-handle.start {
|
||||
left: 0;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.note-handle.end {
|
||||
right: 0;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.lyric-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lyric-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Force fixed height with absolute positioning */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.lyric-bulk {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lyric-bulk-input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.lyric-header,
|
||||
.lyric-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 0.5fr 0.5fr 0.5fr;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lyric-header {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.lyric-list {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.lyric-row {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.lyric-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.lyric-row-active {
|
||||
background: rgba(72, 228, 194, 0.08);
|
||||
border-left: 3px solid #48e4c2;
|
||||
}
|
||||
|
||||
.lyric-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--input-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.lyric-meta {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.editable-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.lyric-meta-input {
|
||||
width: 100%;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
.lyric-meta-input:hover {
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.lyric-meta-input:focus {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.lyric-meta-dirty {
|
||||
border-color: #f59e0b !important;
|
||||
background: rgba(245, 158, 11, 0.1) !important;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.confirm-btn:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
/* Hide number input spinners */
|
||||
.lyric-meta-input::-webkit-outer-spin-button,
|
||||
.lyric-meta-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lyric-meta-input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.lyric-empty {
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audio-track {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--panel-soft);
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audio-track-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.audio-wave {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--text-primary: #e9eef7;
|
||||
--text-muted: rgba(233, 238, 247, 0.7);
|
||||
--panel: rgba(13, 16, 28, 0.8);
|
||||
--panel-strong: rgba(16, 21, 35, 0.95);
|
||||
--panel-soft: rgba(255, 255, 255, 0.03);
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-soft: rgba(255, 255, 255, 0.12);
|
||||
--input-bg: rgba(255, 255, 255, 0.06);
|
||||
--grid-bg: rgba(14, 18, 30, 0.9);
|
||||
--grid-line-minor: rgba(233, 238, 247, 0.08);
|
||||
--grid-line-major: rgba(233, 238, 247, 0.16);
|
||||
--accent: #48e4c2;
|
||||
--accent-strong: #4b64bc;
|
||||
--note-text: #0b1122;
|
||||
--button-ghost-bg: rgba(233, 238, 247, 0.18);
|
||||
--button-ghost-text: #ffffff;
|
||||
--button-soft-bg: rgba(255, 255, 255, 0.14);
|
||||
--button-soft-text: #ffffff;
|
||||
--button-primary-text: #0b1122;
|
||||
--shadow-panel: 0 18px 40px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--text-primary: #1b2238;
|
||||
--text-muted: rgba(27, 34, 56, 0.7);
|
||||
--panel: rgba(255, 255, 255, 0.9);
|
||||
--panel-strong: rgba(250, 252, 255, 0.98);
|
||||
--panel-soft: rgba(15, 23, 42, 0.04);
|
||||
--border-subtle: rgba(15, 23, 42, 0.12);
|
||||
--border-soft: rgba(15, 23, 42, 0.16);
|
||||
--input-bg: rgba(15, 23, 42, 0.06);
|
||||
--grid-bg: rgba(248, 250, 255, 0.95);
|
||||
--grid-line-minor: rgba(15, 23, 42, 0.12);
|
||||
--grid-line-major: rgba(15, 23, 42, 0.24);
|
||||
--accent: #3f8cff;
|
||||
--accent-strong: #4b64bc;
|
||||
--note-text: #ffffff;
|
||||
--button-ghost-bg: rgba(15, 23, 42, 0.06);
|
||||
--button-ghost-text: #1b2238;
|
||||
--button-soft-bg: rgba(15, 23, 42, 0.06);
|
||||
--button-soft-text: #1b2238;
|
||||
--button-primary-text: #0b1122;
|
||||
--shadow-panel: 0 18px 40px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as Tone from 'tone'
|
||||
import { PianoRoll } from './components/PianoRoll'
|
||||
import { LyricTable } from './components/LyricTable'
|
||||
import { AudioTrack } from './components/AudioTrack'
|
||||
import { useMidiStore } from './store/useMidiStore'
|
||||
import { exportMidi, importMidiFile } from './lib/midi'
|
||||
import type { TimeSignature } from './types'
|
||||
import { BASE_GRID_SECOND_WIDTH, BASE_ROW_HEIGHT, LOW_NOTE, HIGH_NOTE } from './constants'
|
||||
import './App.css'
|
||||
|
||||
type PlayEvent = {
|
||||
time: number
|
||||
midi: number
|
||||
duration: number
|
||||
velocity: number
|
||||
}
|
||||
|
||||
function App() {
|
||||
const {
|
||||
notes,
|
||||
tempo,
|
||||
timeSignature,
|
||||
selectedId,
|
||||
playhead,
|
||||
ppq,
|
||||
addNote,
|
||||
updateNote,
|
||||
removeNote,
|
||||
setNotes,
|
||||
setTempo,
|
||||
setTimeSignature,
|
||||
setPpq,
|
||||
select,
|
||||
setPlayhead,
|
||||
} = useMidiStore()
|
||||
|
||||
const [status, setStatus] = useState('准备就绪')
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('light')
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||
const [audioDuration, setAudioDuration] = useState(0)
|
||||
const [midiVolume, setMidiVolume] = useState(80) // 0-100
|
||||
const [audioVolume, setAudioVolume] = useState(80) // 0-100
|
||||
const [horizontalZoom, setHorizontalZoom] = useState(1)
|
||||
const [verticalZoom, setVerticalZoom] = useState(1)
|
||||
const [focusLyricId, setFocusLyricId] = useState<string | null>(null)
|
||||
// Selection range for loop playback (in seconds)
|
||||
const [selectionStart, setSelectionStart] = useState<number | null>(null)
|
||||
const [selectionEnd, setSelectionEnd] = useState<number | null>(null)
|
||||
const [isSelectingRange, setIsSelectingRange] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const audioInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const partRef = useRef<Tone.Part<PlayEvent> | null>(null)
|
||||
const synthRef = useRef<Tone.PolySynth | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const audioScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPlayback()
|
||||
synthRef.current?.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme
|
||||
}, [theme])
|
||||
|
||||
// Sync audio volume - also trigger when audioUrl changes (new audio loaded)
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = audioVolume / 100
|
||||
}
|
||||
}, [audioVolume, audioUrl])
|
||||
|
||||
// Sync MIDI synth volume
|
||||
useEffect(() => {
|
||||
if (synthRef.current) {
|
||||
// Convert 0-100 to dB scale (-60 to 0)
|
||||
const dbValue = midiVolume === 0 ? -Infinity : (midiVolume / 100) * 60 - 60
|
||||
synthRef.current.volume.value = dbValue
|
||||
}
|
||||
}, [midiVolume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioUrl) return
|
||||
return () => {
|
||||
URL.revokeObjectURL(audioUrl)
|
||||
}
|
||||
}, [audioUrl])
|
||||
|
||||
const ensureSynth = async () => {
|
||||
await Tone.start()
|
||||
if (!synthRef.current) {
|
||||
synthRef.current = new Tone.PolySynth(Tone.Synth).toDestination()
|
||||
// Apply current volume
|
||||
const dbValue = midiVolume === 0 ? -Infinity : (midiVolume / 100) * 60 - 60
|
||||
synthRef.current.volume.value = dbValue
|
||||
}
|
||||
}
|
||||
|
||||
const playPreviewNote = useCallback(async (midi: number) => {
|
||||
await ensureSynth()
|
||||
const frequency = Tone.Frequency(midi, 'midi').toFrequency()
|
||||
synthRef.current?.triggerAttackRelease(frequency, '8n', Tone.now(), 0.7)
|
||||
}, [midiVolume])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!selectedId) return
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target && ['INPUT', 'TEXTAREA'].includes(target.tagName)) return
|
||||
|
||||
// Delete note
|
||||
if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||
event.preventDefault()
|
||||
removeNote(selectedId)
|
||||
select(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + Up/Down to adjust pitch
|
||||
const isCmdOrCtrl = event.metaKey || event.ctrlKey
|
||||
if (isCmdOrCtrl && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
|
||||
event.preventDefault()
|
||||
const selectedNote = notes.find(n => n.id === selectedId)
|
||||
if (!selectedNote) return
|
||||
|
||||
const delta = event.key === 'ArrowUp' ? 1 : -1
|
||||
const newMidi = Math.max(LOW_NOTE, Math.min(HIGH_NOTE, selectedNote.midi + delta))
|
||||
|
||||
if (newMidi !== selectedNote.midi) {
|
||||
updateNote(selectedId, { midi: newMidi })
|
||||
playPreviewNote(newMidi)
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [selectedId, notes, removeNote, select, updateNote, playPreviewNote])
|
||||
|
||||
const noteEvents = useMemo<PlayEvent[]>(
|
||||
() =>
|
||||
notes.map((note) => ({
|
||||
time: (60 / tempo) * note.start,
|
||||
duration: (60 / tempo) * note.duration,
|
||||
midi: note.midi,
|
||||
velocity: note.velocity,
|
||||
})),
|
||||
[notes, tempo],
|
||||
)
|
||||
|
||||
const beatToSeconds = (beat: number) => beat * (60 / tempo)
|
||||
const secondsToBeat = (seconds: number) => seconds / (60 / tempo)
|
||||
const seekBySeconds = (deltaSeconds: number) => {
|
||||
const maxNoteEnd = notes.reduce((acc, n) => Math.max(acc, n.start + n.duration), 0)
|
||||
const maxBeat = Math.max(secondsToBeat(audioDuration), maxNoteEnd)
|
||||
const nextSeconds = Math.max(0, Math.min(beatToSeconds(maxBeat), beatToSeconds(playhead) + deltaSeconds))
|
||||
seekToBeat(secondsToBeat(nextSeconds))
|
||||
}
|
||||
|
||||
const gridSecondWidth = BASE_GRID_SECOND_WIDTH * horizontalZoom
|
||||
const rowHeight = BASE_ROW_HEIGHT * verticalZoom
|
||||
|
||||
// Calculate MIDI content width to sync with audio track
|
||||
const midiContentWidth = useMemo(() => {
|
||||
const noteEndSeconds = notes.reduce((acc, n) => {
|
||||
const endBeat = n.start + n.duration
|
||||
return Math.max(acc, beatToSeconds(endBeat))
|
||||
}, 8)
|
||||
const maxSeconds = Math.max(noteEndSeconds + 10, audioDuration + 10, 30)
|
||||
return maxSeconds * gridSecondWidth
|
||||
}, [notes, audioDuration, gridSecondWidth, beatToSeconds])
|
||||
|
||||
const seekToBeat = (beat: number) => {
|
||||
setPlayhead(beat)
|
||||
Tone.Transport.seconds = beatToSeconds(beat)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = beatToSeconds(beat)
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePlayback = async () => {
|
||||
if (!notes.length && !audioUrl) return
|
||||
await ensureSynth()
|
||||
partRef.current?.dispose()
|
||||
Tone.Transport.cancel()
|
||||
Tone.Transport.stop()
|
||||
Tone.Transport.bpm.value = tempo
|
||||
|
||||
// Determine playback range
|
||||
const hasSelection = selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart
|
||||
const startSeconds = hasSelection ? selectionStart : beatToSeconds(playhead)
|
||||
const endSeconds = hasSelection ? selectionEnd : null
|
||||
|
||||
Tone.Transport.seconds = startSeconds
|
||||
|
||||
// Filter notes within selection range if applicable
|
||||
const filteredEvents = hasSelection
|
||||
? noteEvents.filter(e => e.time >= startSeconds && e.time < endSeconds!)
|
||||
: noteEvents
|
||||
|
||||
if (filteredEvents.length) {
|
||||
partRef.current = new Tone.Part((time, event) => {
|
||||
if (midiVolume === 0) return
|
||||
const frequency = Tone.Frequency(event.midi, 'midi').toFrequency()
|
||||
synthRef.current?.triggerAttackRelease(frequency, event.duration, time, event.velocity)
|
||||
}, filteredEvents)
|
||||
partRef.current.start(0)
|
||||
}
|
||||
Tone.Transport.start()
|
||||
if (audioRef.current && audioUrl) {
|
||||
audioRef.current.currentTime = startSeconds
|
||||
if (audioVolume > 0) {
|
||||
audioRef.current.play().catch(() => null)
|
||||
}
|
||||
}
|
||||
setIsPlaying(true)
|
||||
setStatus(hasSelection ? '选区回放中...' : '正在回放...')
|
||||
|
||||
const tick = () => {
|
||||
const seconds =
|
||||
audioRef.current && audioUrl && !audioRef.current.paused
|
||||
? audioRef.current.currentTime
|
||||
: Tone.Transport.seconds
|
||||
|
||||
// Stop at selection end
|
||||
if (endSeconds !== null && seconds >= endSeconds) {
|
||||
pausePlayback()
|
||||
seekToBeat(secondsToBeat(selectionStart!))
|
||||
setStatus('选区播放完毕')
|
||||
return
|
||||
}
|
||||
|
||||
const beat = seconds / (60 / tempo)
|
||||
setPlayhead(beat)
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const stopPlayback = () => {
|
||||
Tone.Transport.stop()
|
||||
Tone.Transport.cancel()
|
||||
partRef.current?.dispose()
|
||||
partRef.current = null
|
||||
setIsPlaying(false)
|
||||
setPlayhead(0)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const pausePlayback = () => {
|
||||
Tone.Transport.stop()
|
||||
partRef.current?.dispose()
|
||||
partRef.current = null
|
||||
setIsPlaying(false)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handlePlayToggle = async () => {
|
||||
if (isPlaying) {
|
||||
pausePlayback()
|
||||
setStatus('已暂停')
|
||||
} else {
|
||||
await schedulePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportClick = () => fileInputRef.current?.click()
|
||||
const handleAudioImportClick = () => audioInputRef.current?.click()
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const snapshot = await importMidiFile(file)
|
||||
setNotes(snapshot.notes)
|
||||
setTempo(snapshot.tempo)
|
||||
setTimeSignature(snapshot.timeSignature as TimeSignature)
|
||||
setPpq(snapshot.ppq) // Preserve original ppq for accurate export
|
||||
setStatus(`已载入 ${file.name}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setStatus('导入失败,请确认文件合法')
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleAudioChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate audio file type
|
||||
const validAudioTypes = ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/flac', 'audio/mp4', 'audio/aac', 'audio/x-m4a']
|
||||
const validExtensions = ['.mp3', '.wav', '.ogg', '.flac', '.m4a', '.aac']
|
||||
const fileName = file.name.toLowerCase()
|
||||
const isValidType = validAudioTypes.includes(file.type) || file.type.startsWith('audio/')
|
||||
const isValidExtension = validExtensions.some(ext => fileName.endsWith(ext))
|
||||
|
||||
if (!isValidType && !isValidExtension) {
|
||||
setStatus(`不支持的文件格式,请选择音频文件(${validExtensions.join(', ')})`)
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file)
|
||||
setAudioUrl(url)
|
||||
setStatus(`已载入音频 ${file.name}`)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// Check for overlapping notes (any pitch)
|
||||
const getOverlappingNotes = () => {
|
||||
const overlapping: string[] = []
|
||||
const sortedNotes = [...notes].sort((a, b) => a.start - b.start)
|
||||
const EPSILON = 0.05 // Tolerance for floating point comparison
|
||||
|
||||
for (let i = 0; i < sortedNotes.length; i++) {
|
||||
for (let j = i + 1; j < sortedNotes.length; j++) {
|
||||
const noteA = sortedNotes[i]
|
||||
const noteB = sortedNotes[j]
|
||||
const noteAEnd = noteA.start + noteA.duration
|
||||
// If noteB starts at or after noteA ends (with tolerance), no overlap
|
||||
if (noteB.start >= noteAEnd - EPSILON) break
|
||||
// True overlap: noteB starts before noteA ends
|
||||
if (!overlapping.includes(noteA.id)) overlapping.push(noteA.id)
|
||||
if (!overlapping.includes(noteB.id)) overlapping.push(noteB.id)
|
||||
}
|
||||
}
|
||||
return overlapping
|
||||
}
|
||||
|
||||
// Auto-fix overlapping notes by trimming the first note to end where the second begins
|
||||
const handleFixOverlaps = () => {
|
||||
const sortedNotes = [...notes].sort((a, b) => a.start - b.start)
|
||||
let fixCount = 0
|
||||
|
||||
for (let i = 0; i < sortedNotes.length - 1; i++) {
|
||||
const noteA = sortedNotes[i]
|
||||
const noteB = sortedNotes[i + 1]
|
||||
const noteAEnd = noteA.start + noteA.duration
|
||||
|
||||
// If noteA overlaps with noteB
|
||||
if (noteAEnd > noteB.start) {
|
||||
// Trim noteA to end at noteB's start
|
||||
const newDuration = Math.max(0.01, noteB.start - noteA.start)
|
||||
updateNote(noteA.id, { duration: newDuration })
|
||||
fixCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (fixCount > 0) {
|
||||
setStatus(`已修复 ${fixCount} 个重叠音符`)
|
||||
} else {
|
||||
setStatus('没有检测到重叠音符')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const overlapping = getOverlappingNotes()
|
||||
if (overlapping.length > 0) {
|
||||
const confirm = window.confirm(
|
||||
`检测到 ${overlapping.length} 个音符存在时间重叠(标红色的音符),这可能导致播放异常。\n\n是否仍要导出?`
|
||||
)
|
||||
if (!confirm) return
|
||||
}
|
||||
|
||||
const blob = exportMidi({ notes, tempo, timeSignature, ppq })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = 'vocal-midi.mid'
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setStatus('已导出包含歌词的 MIDI 文件')
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">歌声 MIDI 编辑器</p>
|
||||
<h1>Lyric-ready Piano Roll</h1>
|
||||
<p className="muted">导入、拖拽、实时修改歌词并导出标准 MIDI。</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="icon-toggle" onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||
{theme === 'dark' ? (
|
||||
<span className="icon" aria-label="切换到亮色">
|
||||
☀️
|
||||
</span>
|
||||
) : (
|
||||
<span className="icon" aria-label="切换到暗色">
|
||||
🌙
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button className="primary" onClick={handleImportClick}>
|
||||
导入 MIDI
|
||||
</button>
|
||||
<button className="primary" onClick={handleExport}>
|
||||
导出含歌词 MIDI
|
||||
</button>
|
||||
<button className="soft" onClick={handleFixOverlaps} title="自动消除重叠:将重叠音符的音尾提前到下一个音的音头">
|
||||
消除重叠
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".mid,.midi" className="sr-only" onChange={handleFileChange} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="audio-bar">
|
||||
<div className="audio-left">
|
||||
<button className="ghost" onClick={handleAudioImportClick}>
|
||||
对齐音频导入
|
||||
</button>
|
||||
<input
|
||||
ref={audioInputRef}
|
||||
type="file"
|
||||
accept=".mp3,.wav,.ogg,.flac,.m4a,.aac"
|
||||
className="sr-only"
|
||||
onChange={handleAudioChange}
|
||||
/>
|
||||
<span className="audio-hint">导入后显示音频波形并与 MIDI 同步走带</span>
|
||||
</div>
|
||||
<div className="audio-right">
|
||||
<div className="volume-control">
|
||||
<span className="volume-label">MIDI</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={midiVolume}
|
||||
onChange={(e) => setMidiVolume(Number(e.target.value))}
|
||||
className="volume-slider"
|
||||
/>
|
||||
<span className="volume-value">{midiVolume}%</span>
|
||||
</div>
|
||||
<div className="volume-control">
|
||||
<span className="volume-label">音频</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={audioVolume}
|
||||
onChange={(e) => setAudioVolume(Number(e.target.value))}
|
||||
className="volume-slider"
|
||||
/>
|
||||
<span className="volume-value">{audioVolume}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel panel-split">
|
||||
<div className="panel-main">
|
||||
{audioUrl && (
|
||||
<AudioTrack
|
||||
key={audioUrl}
|
||||
ref={audioScrollRef}
|
||||
audioUrl={audioUrl}
|
||||
muted={audioVolume === 0}
|
||||
onSeek={(seconds) => seekToBeat(secondsToBeat(seconds))}
|
||||
playheadSeconds={beatToSeconds(playhead)}
|
||||
gridSecondWidth={gridSecondWidth}
|
||||
minContentWidth={midiContentWidth}
|
||||
/>
|
||||
)}
|
||||
<PianoRoll
|
||||
notes={notes}
|
||||
selectedId={selectedId}
|
||||
timeSignature={timeSignature}
|
||||
tempo={tempo}
|
||||
playhead={playhead}
|
||||
selectionStart={selectionStart}
|
||||
selectionEnd={selectionEnd}
|
||||
onAddNote={addNote}
|
||||
onSelect={select}
|
||||
onUpdateNote={updateNote}
|
||||
onSeek={seekToBeat}
|
||||
onScroll={(left) => {
|
||||
if (audioScrollRef.current) {
|
||||
audioScrollRef.current.scrollLeft = left
|
||||
}
|
||||
}}
|
||||
onZoom={(deltaH, deltaV) => {
|
||||
if (deltaH !== 0) {
|
||||
setHorizontalZoom(prev => Math.max(0.5, prev + deltaH))
|
||||
}
|
||||
if (deltaV !== 0) {
|
||||
setVerticalZoom(prev => Math.max(0.6, Math.min(2.5, prev + deltaV)))
|
||||
}
|
||||
}}
|
||||
onPlayNote={playPreviewNote}
|
||||
onFocusLyric={(noteId) => {
|
||||
select(noteId)
|
||||
setFocusLyricId(noteId)
|
||||
}}
|
||||
onSelectionChange={(start, end) => {
|
||||
setSelectionStart(start)
|
||||
setSelectionEnd(end)
|
||||
}}
|
||||
isSelectingRange={isSelectingRange}
|
||||
audioDuration={audioDuration}
|
||||
gridSecondWidth={gridSecondWidth}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
</div>
|
||||
<aside className="panel-side">
|
||||
<div className="controls">
|
||||
<div className="toggle" style={{ justifyContent: 'space-between' }}>
|
||||
<span>水平缩放</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.5}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={Math.min(horizontalZoom, 10)}
|
||||
onChange={(e) => setHorizontalZoom(Number(e.target.value))}
|
||||
style={{ width: '140px' }}
|
||||
/>
|
||||
<span style={{ width: 42, textAlign: 'right' }}>{horizontalZoom.toFixed(1)}x</span>
|
||||
</div>
|
||||
<div className="toggle" style={{ justifyContent: 'space-between' }}>
|
||||
<span>垂直缩放</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.6}
|
||||
max={2.5}
|
||||
step={0.1}
|
||||
value={verticalZoom}
|
||||
onChange={(e) => setVerticalZoom(Number(e.target.value))}
|
||||
style={{ width: '140px' }}
|
||||
/>
|
||||
<span style={{ width: 42, textAlign: 'right' }}>{verticalZoom.toFixed(1)}x</span>
|
||||
</div>
|
||||
<div className="transport">
|
||||
<button
|
||||
className="soft"
|
||||
onClick={() => {
|
||||
setPlayhead(0)
|
||||
seekToBeat(0)
|
||||
}}
|
||||
title="回到开头"
|
||||
>
|
||||
⏮
|
||||
</button>
|
||||
<button
|
||||
className="soft"
|
||||
onClick={() => seekBySeconds(-2)}
|
||||
title="后退 2 秒"
|
||||
>
|
||||
⏪ 2s
|
||||
</button>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={handlePlayToggle}
|
||||
disabled={!notes.length && !audioUrl}
|
||||
title={isPlaying ? "暂停" : (selectionStart !== null && selectionEnd !== null ? "播放选区" : "播放")}
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
className="soft"
|
||||
onClick={() => seekBySeconds(2)}
|
||||
title="前进 2 秒"
|
||||
>
|
||||
2s ⏩
|
||||
</button>
|
||||
<button
|
||||
className="soft"
|
||||
onClick={() => {
|
||||
// Logic to find end of song? Max note end or audio duration
|
||||
const maxNoteEnd = notes.reduce((acc, n) => Math.max(acc, n.start + n.duration), 0)
|
||||
seekToBeat(Math.max(secondsToBeat(audioDuration), maxNoteEnd))
|
||||
}}
|
||||
title="回到结尾"
|
||||
>
|
||||
⏭
|
||||
</button>
|
||||
</div>
|
||||
<div className="selection-controls">
|
||||
<button
|
||||
className={`soft selection-btn ${isSelectingRange ? 'active' : ''}`}
|
||||
onClick={() => setIsSelectingRange(!isSelectingRange)}
|
||||
title={isSelectingRange ? "退出选区模式" : "设置选区:在时间轴上拖拽选择播放范围"}
|
||||
>
|
||||
{isSelectingRange ? '📍 选区中' : '📍 设选区'}
|
||||
</button>
|
||||
{selectionStart !== null && selectionEnd !== null && (
|
||||
<>
|
||||
<span className="selection-info">
|
||||
{selectionStart.toFixed(1)}s - {selectionEnd.toFixed(1)}s
|
||||
</span>
|
||||
<button
|
||||
className="soft"
|
||||
onClick={() => {
|
||||
setSelectionStart(null)
|
||||
setSelectionEnd(null)
|
||||
}}
|
||||
title="清除选区"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="status">{status}</div>
|
||||
</div>
|
||||
<div className="lyric-container">
|
||||
<LyricTable
|
||||
notes={notes}
|
||||
selectedId={selectedId}
|
||||
tempo={tempo}
|
||||
focusLyricId={focusLyricId}
|
||||
onSelect={select}
|
||||
onUpdate={updateNote}
|
||||
onFocusHandled={() => setFocusLyricId(null)}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl ?? undefined}
|
||||
preload="auto"
|
||||
className="sr-only"
|
||||
onLoadedMetadata={(e) => {
|
||||
setAudioDuration(e.currentTarget.duration)
|
||||
// Ensure volume is set when audio loads
|
||||
e.currentTarget.volume = audioVolume / 100
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useEffect, useRef, forwardRef, useState } from 'react'
|
||||
import WaveSurfer from 'wavesurfer.js'
|
||||
import { PITCH_WIDTH } from '../constants'
|
||||
|
||||
export type AudioTrackProps = {
|
||||
audioUrl: string | null
|
||||
muted: boolean
|
||||
onSeek: (seconds: number) => void
|
||||
mediaElement?: HTMLAudioElement | null
|
||||
playheadSeconds: number
|
||||
gridSecondWidth: number
|
||||
minContentWidth?: number // Minimum width to match MIDI editor area
|
||||
}
|
||||
|
||||
export const AudioTrack = forwardRef<HTMLDivElement, AudioTrackProps>(
|
||||
({ audioUrl, muted, onSeek, playheadSeconds, gridSecondWidth, minContentWidth = 0 }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const waveRef = useRef<WaveSurfer | null>(null)
|
||||
const [waveWidth, setWaveWidth] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
if (!audioUrl) {
|
||||
try {
|
||||
waveRef.current?.destroy()
|
||||
} catch {
|
||||
// ignore teardown errors
|
||||
}
|
||||
waveRef.current = null
|
||||
setWaveWidth(0)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// Clean up existing instance
|
||||
if (waveRef.current) {
|
||||
try {
|
||||
waveRef.current.destroy()
|
||||
} catch {
|
||||
// ignore teardown errors
|
||||
}
|
||||
}
|
||||
|
||||
waveRef.current = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
waveColor: '#4b64bc',
|
||||
progressColor: '#4b64bc',
|
||||
cursorColor: 'transparent',
|
||||
barWidth: 2,
|
||||
barGap: 2,
|
||||
height: 60,
|
||||
normalize: true,
|
||||
minPxPerSec: gridSecondWidth,
|
||||
interact: false,
|
||||
hideScrollbar: true,
|
||||
autoScroll: false,
|
||||
})
|
||||
|
||||
waveRef.current.load(audioUrl).catch(() => null)
|
||||
waveRef.current.on('error', () => null)
|
||||
|
||||
waveRef.current.on('ready', () => {
|
||||
if (cancelled || !waveRef.current) return
|
||||
const duration = waveRef.current.getDuration()
|
||||
const requiredWidth = duration * gridSecondWidth
|
||||
setWaveWidth(requiredWidth)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
try {
|
||||
waveRef.current?.destroy()
|
||||
} catch {
|
||||
// ignore teardown errors
|
||||
}
|
||||
waveRef.current = null
|
||||
}
|
||||
}, [audioUrl, gridSecondWidth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!waveRef.current) return
|
||||
waveRef.current.setOptions({
|
||||
waveColor: muted ? '#9aa6b2' : '#4b64bc',
|
||||
progressColor: muted ? '#c0c9d4' : '#4b64bc',
|
||||
})
|
||||
}, [muted])
|
||||
|
||||
if (!audioUrl) return null
|
||||
|
||||
// Content width should be at least as wide as MIDI editor
|
||||
const contentWidth = Math.max(waveWidth, minContentWidth)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="audio-track-row"
|
||||
style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid var(--border-soft)',
|
||||
height: '70px',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="audio-gutter"
|
||||
style={{
|
||||
width: PITCH_WIDTH,
|
||||
flexShrink: 0,
|
||||
background: 'var(--panel-strong)',
|
||||
borderRight: '1px solid var(--border-subtle)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
AUDIO
|
||||
</div>
|
||||
|
||||
{/* Scroll Mask - Controlled by parent via ref */}
|
||||
<div
|
||||
ref={ref}
|
||||
className="audio-scroll-mask"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
background: 'var(--panel-soft)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const scrollMask = e.currentTarget as HTMLDivElement
|
||||
const x = e.clientX - rect.left + scrollMask.scrollLeft
|
||||
const seconds = x / gridSecondWidth
|
||||
onSeek(seconds)
|
||||
}}
|
||||
>
|
||||
{/* Container that matches MIDI editor width */}
|
||||
<div
|
||||
className="audio-content"
|
||||
style={{
|
||||
width: contentWidth > 0 ? contentWidth : '100%',
|
||||
height: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{/* WaveSurfer container - only as wide as audio */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="wave-container"
|
||||
style={{
|
||||
width: waveWidth > 0 ? waveWidth : '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Custom Playhead */}
|
||||
<div
|
||||
className="audio-playhead"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '2px',
|
||||
background: '#ff7043',
|
||||
boxShadow: '0 0 12px rgba(255, 112, 67, 0.6)',
|
||||
left: playheadSeconds * gridSecondWidth,
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { NoteEvent } from '../types'
|
||||
|
||||
export type LyricTableProps = {
|
||||
notes: NoteEvent[]
|
||||
selectedId: string | null
|
||||
tempo: number
|
||||
focusLyricId: string | null
|
||||
onSelect: (id: string | null) => void
|
||||
onUpdate: (id: string, patch: Partial<NoteEvent>) => void
|
||||
onScrollToNote?: (noteId: string) => void
|
||||
onFocusHandled?: () => void
|
||||
}
|
||||
|
||||
const formatSeconds = (beats: number, tempo: number) => {
|
||||
const seconds = beats * (60 / tempo)
|
||||
return Number.parseFloat(seconds.toFixed(2))
|
||||
}
|
||||
|
||||
const secondsToBeats = (seconds: number, tempo: number) => {
|
||||
return seconds * (tempo / 60)
|
||||
}
|
||||
|
||||
// Editable cell with confirmation
|
||||
function EditableCell({
|
||||
value,
|
||||
noteId,
|
||||
field,
|
||||
tempo,
|
||||
onConfirm,
|
||||
type = 'number',
|
||||
min,
|
||||
step
|
||||
}: {
|
||||
value: number
|
||||
noteId: string
|
||||
field: 'midi' | 'start' | 'end'
|
||||
tempo: number
|
||||
onConfirm: (noteId: string, field: string, value: number) => void
|
||||
type?: string
|
||||
min?: number
|
||||
step?: number
|
||||
}) {
|
||||
const displayValue = field === 'midi' ? value : formatSeconds(value, tempo)
|
||||
const [localValue, setLocalValue] = useState(String(displayValue))
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Sync with external value when it changes (and not dirty)
|
||||
useEffect(() => {
|
||||
if (!isDirty) {
|
||||
setLocalValue(String(displayValue))
|
||||
}
|
||||
}, [displayValue, isDirty])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value)
|
||||
setIsDirty(true)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const parsed = parseFloat(localValue)
|
||||
if (!isNaN(parsed)) {
|
||||
if (field === 'midi') {
|
||||
if (parsed >= 0 && parsed <= 127) {
|
||||
onConfirm(noteId, field, Math.round(parsed))
|
||||
}
|
||||
} else {
|
||||
if (parsed >= 0) {
|
||||
onConfirm(noteId, field, secondsToBeats(parsed, tempo))
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsDirty(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleConfirm()
|
||||
inputRef.current?.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setLocalValue(String(displayValue))
|
||||
setIsDirty(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (isDirty) {
|
||||
// Reset to original on blur without confirm
|
||||
setLocalValue(String(displayValue))
|
||||
setIsDirty(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editable-cell">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={`lyric-meta-input ${isDirty ? 'lyric-meta-dirty' : ''}`}
|
||||
type={type}
|
||||
min={min}
|
||||
step={step}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{isDirty && (
|
||||
<button
|
||||
className="confirm-btn"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // Prevent input blur
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleConfirm()
|
||||
}}
|
||||
title="确认修改 (Enter)"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LyricTable({ notes, selectedId, tempo, focusLyricId, onSelect, onUpdate, onScrollToNote, onFocusHandled }: LyricTableProps) {
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRefs = useRef<Map<string, HTMLInputElement>>(new Map())
|
||||
const sorted = useMemo(() => [...notes].sort((a, b) => a.start - b.start), [notes])
|
||||
|
||||
// Scroll to selected note (no auto-focus on single click)
|
||||
useEffect(() => {
|
||||
if (!selectedId || !listRef.current) return
|
||||
|
||||
const target = listRef.current.querySelector<HTMLDivElement>(`[data-note-id="${selectedId}"]`)
|
||||
if (target) {
|
||||
target.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
}, [selectedId])
|
||||
|
||||
// Focus lyric input when requested (double-click on note or click on list row)
|
||||
useEffect(() => {
|
||||
if (!focusLyricId) return
|
||||
|
||||
const input = inputRefs.current.get(focusLyricId)
|
||||
if (input) {
|
||||
setTimeout(() => {
|
||||
input.focus()
|
||||
input.select()
|
||||
}, 50)
|
||||
}
|
||||
onFocusHandled?.()
|
||||
}, [focusLyricId, onFocusHandled])
|
||||
|
||||
// Fill lyrics from selected note onwards
|
||||
const handleBulkFill = (bulkText: string) => {
|
||||
if (!sorted.length) return
|
||||
const chars = Array.from(bulkText.replace(/\s+/g, ''))
|
||||
|
||||
let startIndex = 0
|
||||
if (selectedId) {
|
||||
const selectedIndex = sorted.findIndex(n => n.id === selectedId)
|
||||
if (selectedIndex >= 0) {
|
||||
startIndex = selectedIndex
|
||||
}
|
||||
}
|
||||
|
||||
let charIndex = 0
|
||||
for (let i = startIndex; i < sorted.length && charIndex < chars.length; i++) {
|
||||
onUpdate(sorted[i].id, { lyric: chars[charIndex] })
|
||||
charIndex++
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowClick = (noteId: string) => {
|
||||
onSelect(noteId)
|
||||
onScrollToNote?.(noteId)
|
||||
}
|
||||
|
||||
const handleFieldConfirm = (noteId: string, field: string, value: number) => {
|
||||
const note = notes.find(n => n.id === noteId)
|
||||
if (!note) return
|
||||
|
||||
if (field === 'midi') {
|
||||
onUpdate(noteId, { midi: value })
|
||||
} else if (field === 'start') {
|
||||
// Keep END the same, adjust duration accordingly
|
||||
const currentEnd = note.start + note.duration
|
||||
const newDuration = Math.max(0.01, currentEnd - value)
|
||||
onUpdate(noteId, { start: value, duration: newDuration })
|
||||
} else if (field === 'end') {
|
||||
// End changed, update duration
|
||||
const newDuration = Math.max(0.01, value - note.start)
|
||||
onUpdate(noteId, { duration: newDuration })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lyric-card">
|
||||
<div className="lyric-bulk">
|
||||
<textarea
|
||||
className="lyric-bulk-input"
|
||||
rows={2}
|
||||
placeholder={selectedId ? "从选中音符开始按字填充" : "输入歌词,点击按字填充"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleBulkFill(e.currentTarget.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="soft"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
const textarea = e.currentTarget.previousElementSibling as HTMLTextAreaElement
|
||||
handleBulkFill(textarea.value)
|
||||
}}
|
||||
>
|
||||
按字<br/>填充
|
||||
</button>
|
||||
</div>
|
||||
<div className="lyric-header" style={{ flexShrink: 0 }}>
|
||||
<div>LYRIC</div>
|
||||
<div>PITCH</div>
|
||||
<div>START</div>
|
||||
<div>END</div>
|
||||
</div>
|
||||
<div className="lyric-list" ref={listRef}>
|
||||
{sorted.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className={`lyric-row ${selectedId === note.id ? 'lyric-row-active' : ''}`}
|
||||
data-note-id={note.id}
|
||||
onClick={() => handleRowClick(note.id)}
|
||||
>
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
inputRefs.current.set(note.id, el)
|
||||
} else {
|
||||
inputRefs.current.delete(note.id)
|
||||
}
|
||||
}}
|
||||
className="lyric-input"
|
||||
value={note.lyric}
|
||||
placeholder="Type lyric"
|
||||
onChange={(event) => onUpdate(note.id, { lyric: event.target.value })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<EditableCell
|
||||
value={note.midi}
|
||||
noteId={note.id}
|
||||
field="midi"
|
||||
tempo={tempo}
|
||||
onConfirm={handleFieldConfirm}
|
||||
min={0}
|
||||
/>
|
||||
<EditableCell
|
||||
value={note.start}
|
||||
noteId={note.id}
|
||||
field="start"
|
||||
tempo={tempo}
|
||||
onConfirm={handleFieldConfirm}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
<EditableCell
|
||||
value={note.start + note.duration}
|
||||
noteId={note.id}
|
||||
field="end"
|
||||
tempo={tempo}
|
||||
onConfirm={handleFieldConfirm}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sorted.length === 0 && <div className="lyric-empty">Import或双击钢琴卷帘以添加音符</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback, memo } from 'react'
|
||||
import type React from 'react'
|
||||
import type { NoteEvent, TimeSignature } from '../types'
|
||||
import { PITCH_WIDTH, LOW_NOTE, HIGH_NOTE } from '../constants'
|
||||
|
||||
const midiToName = (midi: number) => {
|
||||
const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
const octave = Math.floor(midi / 12) - 1
|
||||
return `${names[midi % 12]}${octave}`
|
||||
}
|
||||
|
||||
// Memoized note component to prevent unnecessary re-renders
|
||||
const NoteChip = memo(function NoteChip({
|
||||
note,
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
fontSize,
|
||||
isSelected,
|
||||
isOverlapping,
|
||||
onPointerDown,
|
||||
onDoubleClick,
|
||||
}: {
|
||||
note: NoteEvent
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
fontSize: number
|
||||
isSelected: boolean
|
||||
isOverlapping: boolean
|
||||
onPointerDown: (event: React.PointerEvent<HTMLDivElement>, mode: 'move' | 'resize-start' | 'resize-end') => void
|
||||
onDoubleClick: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`note-chip ${isSelected ? 'note-active' : ''} ${isOverlapping ? 'note-overlap' : ''}`}
|
||||
style={{
|
||||
left,
|
||||
top: top + 1,
|
||||
width,
|
||||
height,
|
||||
willChange: 'transform', // GPU acceleration hint
|
||||
}}
|
||||
onPointerDown={(e) => onPointerDown(e, 'move')}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<div className="note-label" style={{ fontSize }}>
|
||||
<span>{note.lyric || '\u00a0'}</span>
|
||||
</div>
|
||||
<div className="note-handle start" onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e, 'resize-start') }} />
|
||||
<div className="note-handle end" onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e, 'resize-end') }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Dynamic snap based on zoom level - higher zoom = finer snap
|
||||
const getSnapSeconds = (gridSecondWidth: number) => {
|
||||
// At base width (80px/s), snap is 0.1s
|
||||
// At 2x zoom (160px/s), snap is 0.05s
|
||||
// At 4x zoom (320px/s), snap is 0.025s
|
||||
// At 8x zoom (640px/s), snap is 0.01s
|
||||
const baseSnap = 0.1
|
||||
const zoomFactor = gridSecondWidth / 80
|
||||
return Math.max(0.01, baseSnap / zoomFactor)
|
||||
}
|
||||
|
||||
const snapSeconds = (value: number, gridSecondWidth: number) => {
|
||||
const snap = getSnapSeconds(gridSecondWidth)
|
||||
return Math.max(0, Math.round(value / snap) * snap)
|
||||
}
|
||||
|
||||
export type PianoRollProps = {
|
||||
notes: NoteEvent[]
|
||||
selectedId: string | null
|
||||
timeSignature: TimeSignature
|
||||
tempo: number
|
||||
playhead: number // in beats
|
||||
selectionStart: number | null // in seconds
|
||||
selectionEnd: number | null // in seconds
|
||||
onAddNote: (note: Partial<NoteEvent>) => NoteEvent
|
||||
onUpdateNote: (id: string, patch: Partial<NoteEvent>) => void
|
||||
onSelect: (id: string | null) => void
|
||||
onSeek: (beat: number) => void
|
||||
onScroll?: (left: number) => void
|
||||
onZoom?: (deltaH: number, deltaV: number) => void
|
||||
onPlayNote?: (midi: number) => void
|
||||
onFocusLyric?: (noteId: string) => void
|
||||
onSelectionChange?: (start: number | null, end: number | null) => void
|
||||
isSelectingRange?: boolean
|
||||
audioDuration?: number
|
||||
gridSecondWidth: number
|
||||
rowHeight: number
|
||||
}
|
||||
|
||||
export function PianoRoll({
|
||||
notes,
|
||||
selectedId,
|
||||
timeSignature: _timeSignature,
|
||||
tempo,
|
||||
playhead,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
onAddNote,
|
||||
onSelect,
|
||||
onUpdateNote,
|
||||
onSeek,
|
||||
onScroll,
|
||||
onZoom,
|
||||
onPlayNote,
|
||||
onFocusLyric,
|
||||
onSelectionChange,
|
||||
isSelectingRange = false,
|
||||
audioDuration = 0,
|
||||
gridSecondWidth,
|
||||
rowHeight
|
||||
}: PianoRollProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const rulerScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const [scrollLeft, setScrollLeft] = useState(0)
|
||||
const [viewportWidth, setViewportWidth] = useState(800)
|
||||
const [viewportHeight, setViewportHeight] = useState(400)
|
||||
const dragRef = useRef<{
|
||||
id: string
|
||||
mode: 'move' | 'resize-start' | 'resize-end'
|
||||
originX: number
|
||||
originY: number
|
||||
startSeconds: number
|
||||
durationSeconds: number
|
||||
midi: number
|
||||
lastMidi?: number // Track last midi for pitch change sound
|
||||
} | null>(null)
|
||||
|
||||
// Selection drag state
|
||||
const selectionDragRef = useRef<{
|
||||
startX: number
|
||||
startSeconds: number
|
||||
} | null>(null)
|
||||
|
||||
// Store callbacks in refs to avoid stale closures in event handlers
|
||||
const onPlayNoteRef = useRef(onPlayNote)
|
||||
const onUpdateNoteRef = useRef(onUpdateNote)
|
||||
|
||||
useEffect(() => {
|
||||
onPlayNoteRef.current = onPlayNote
|
||||
onUpdateNoteRef.current = onUpdateNote
|
||||
}, [onPlayNote, onUpdateNote])
|
||||
|
||||
// Conversion helpers
|
||||
const beatToSeconds = useCallback((beat: number) => beat * (60 / tempo), [tempo])
|
||||
const secondsToBeat = useCallback((seconds: number) => seconds / (60 / tempo), [tempo])
|
||||
|
||||
// Calculate dimensions
|
||||
const totalRows = HIGH_NOTE - LOW_NOTE + 1
|
||||
const contentHeight = totalRows * rowHeight
|
||||
const [containerWidth, setContainerWidth] = useState(1200)
|
||||
|
||||
// Track container size
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width)
|
||||
setViewportWidth(entry.contentRect.width)
|
||||
setViewportHeight(entry.contentRect.height)
|
||||
}
|
||||
})
|
||||
observer.observe(container)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const maxSeconds = useMemo(() => {
|
||||
const noteEndSeconds = notes.reduce((acc, n) => {
|
||||
const endBeat = n.start + n.duration
|
||||
return Math.max(acc, beatToSeconds(endBeat))
|
||||
}, 8)
|
||||
// Ensure grid extends at least 2x the visible area for smoother scrolling
|
||||
const minSecondsForView = (containerWidth / gridSecondWidth) * 2
|
||||
return Math.max(noteEndSeconds + 10, audioDuration + 10, minSecondsForView, 30)
|
||||
}, [notes, audioDuration, beatToSeconds, containerWidth, gridSecondWidth])
|
||||
|
||||
const contentWidth = maxSeconds * gridSecondWidth
|
||||
|
||||
// Drag handlers - use refs to avoid stale closure issues
|
||||
const handlePointerMove = useCallback((event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
|
||||
const dxSeconds = (event.clientX - drag.originX) / gridSecondWidth
|
||||
const dy = (event.clientY - drag.originY) / rowHeight
|
||||
|
||||
if (drag.mode === 'move') {
|
||||
const nextSeconds = snapSeconds(drag.startSeconds + dxSeconds, gridSecondWidth)
|
||||
const nextMidi = Math.min(HIGH_NOTE, Math.max(LOW_NOTE, Math.round(drag.midi - dy)))
|
||||
|
||||
// Play sound when pitch changes
|
||||
if (nextMidi !== drag.lastMidi && onPlayNoteRef.current) {
|
||||
onPlayNoteRef.current(nextMidi)
|
||||
drag.lastMidi = nextMidi
|
||||
}
|
||||
|
||||
onUpdateNoteRef.current(drag.id, {
|
||||
start: secondsToBeat(nextSeconds),
|
||||
midi: nextMidi
|
||||
})
|
||||
}
|
||||
|
||||
if (drag.mode === 'resize-start') {
|
||||
const nextSeconds = snapSeconds(drag.startSeconds + dxSeconds, gridSecondWidth)
|
||||
const delta = drag.startSeconds - nextSeconds
|
||||
const nextDurationSeconds = Math.max(0.05, drag.durationSeconds + delta)
|
||||
onUpdateNoteRef.current(drag.id, {
|
||||
start: secondsToBeat(nextSeconds),
|
||||
duration: secondsToBeat(nextDurationSeconds)
|
||||
})
|
||||
}
|
||||
|
||||
if (drag.mode === 'resize-end') {
|
||||
const nextDurationSeconds = Math.max(0.05, snapSeconds(drag.durationSeconds + dxSeconds, gridSecondWidth))
|
||||
onUpdateNoteRef.current(drag.id, { duration: secondsToBeat(nextDurationSeconds) })
|
||||
}
|
||||
}, [gridSecondWidth, rowHeight, secondsToBeat])
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
dragRef.current = null
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}, [handlePointerMove])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [handlePointerMove, handlePointerUp])
|
||||
|
||||
// Scroll sync
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
const ruler = rulerScrollRef.current
|
||||
if (!container || !ruler) return
|
||||
|
||||
const handleScroll = () => {
|
||||
ruler.scrollLeft = container.scrollLeft
|
||||
setScrollTop(container.scrollTop)
|
||||
setScrollLeft(container.scrollLeft)
|
||||
if (onScroll) onScroll(container.scrollLeft)
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [onScroll])
|
||||
|
||||
// Zoom support via wheel/trackpad
|
||||
// Mac: Cmd+滚轮 (水平缩放), Cmd+Shift+滚轮 (垂直缩放), 或双指捏合
|
||||
// Windows/Linux: Ctrl+滚轮 (水平缩放), Ctrl+Shift+滚轮 (垂直缩放)
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || !onZoom) return
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// Ctrl (Windows/Linux/捏合) or Cmd (Mac) triggers zoom
|
||||
const isZoomTrigger = e.ctrlKey || e.metaKey
|
||||
|
||||
if (isZoomTrigger) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Use deltaY for zoom amount, normalize for different input methods
|
||||
// Pinch gestures typically have smaller delta values
|
||||
let delta = -e.deltaY
|
||||
if (Math.abs(delta) > 10) {
|
||||
// Likely a mouse wheel, scale down
|
||||
delta = delta * 0.01
|
||||
} else {
|
||||
// Likely a trackpad pinch, scale appropriately
|
||||
delta = delta * 0.05
|
||||
}
|
||||
|
||||
// Shift or Alt/Option for vertical zoom, otherwise horizontal
|
||||
if (e.shiftKey || e.altKey) {
|
||||
onZoom(0, delta)
|
||||
} else {
|
||||
onZoom(delta, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}, [onZoom])
|
||||
|
||||
// Playhead auto-scroll
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current) return
|
||||
const container = scrollContainerRef.current
|
||||
const playheadX = beatToSeconds(playhead) * gridSecondWidth
|
||||
const viewStart = container.scrollLeft
|
||||
const viewEnd = viewStart + container.clientWidth
|
||||
|
||||
if (playheadX > viewEnd) {
|
||||
container.scrollLeft = playheadX
|
||||
} else if (playheadX < viewStart) {
|
||||
container.scrollLeft = playheadX
|
||||
}
|
||||
}, [playhead, gridSecondWidth, beatToSeconds])
|
||||
|
||||
// Selection auto-scroll
|
||||
useEffect(() => {
|
||||
if (!scrollContainerRef.current || !selectedId) return
|
||||
const note = notes.find((n) => n.id === selectedId)
|
||||
if (!note) return
|
||||
const container = scrollContainerRef.current
|
||||
const noteX = beatToSeconds(note.start) * gridSecondWidth
|
||||
const noteY = (HIGH_NOTE - note.midi) * rowHeight
|
||||
|
||||
const viewStart = container.scrollLeft
|
||||
const viewEnd = viewStart + container.clientWidth
|
||||
if (noteX < viewStart + 50 || noteX > viewEnd - 50) {
|
||||
container.scrollLeft = Math.max(0, noteX - container.clientWidth * 0.35)
|
||||
}
|
||||
|
||||
const viewTop = container.scrollTop
|
||||
const viewBottom = viewTop + container.clientHeight
|
||||
if (noteY < viewTop || noteY > viewBottom - rowHeight) {
|
||||
container.scrollTop = Math.max(0, noteY - container.clientHeight * 0.4)
|
||||
}
|
||||
}, [selectedId, notes, gridSecondWidth, rowHeight, beatToSeconds])
|
||||
|
||||
const handleGridDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only add note if clicking on empty space (not on a note)
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('.note-chip')) return
|
||||
|
||||
if (!scrollContainerRef.current) return
|
||||
const container = scrollContainerRef.current
|
||||
const rect = container.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left + container.scrollLeft
|
||||
const y = event.clientY - rect.top + container.scrollTop
|
||||
|
||||
const seconds = snapSeconds(x / gridSecondWidth, gridSecondWidth)
|
||||
const pitch = Math.min(HIGH_NOTE, Math.max(LOW_NOTE, HIGH_NOTE - Math.floor(y / rowHeight)))
|
||||
|
||||
const created = onAddNote({
|
||||
start: secondsToBeat(seconds),
|
||||
midi: pitch,
|
||||
duration: secondsToBeat(0.5),
|
||||
lyric: ''
|
||||
})
|
||||
onSelect(created.id)
|
||||
}
|
||||
|
||||
const startDrag = (
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
note: NoteEvent,
|
||||
mode: 'move' | 'resize-start' | 'resize-end',
|
||||
) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragRef.current = {
|
||||
id: note.id,
|
||||
mode,
|
||||
originX: event.clientX,
|
||||
originY: event.clientY,
|
||||
startSeconds: beatToSeconds(note.start),
|
||||
durationSeconds: beatToSeconds(note.duration),
|
||||
midi: note.midi,
|
||||
lastMidi: note.midi, // Initialize last midi
|
||||
}
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
onSelect(note.id)
|
||||
|
||||
// Play sound when clicking/selecting note
|
||||
if (onPlayNote) {
|
||||
onPlayNote(note.midi)
|
||||
}
|
||||
}
|
||||
|
||||
// Second-based ruler labels
|
||||
const secondLabels = useMemo(() => {
|
||||
const labels = [] as Array<{ left: number; label: string }>
|
||||
const totalSeconds = Math.ceil(maxSeconds)
|
||||
for (let s = 0; s <= totalSeconds; s += 1) {
|
||||
labels.push({ left: s * gridSecondWidth, label: `${s}s` })
|
||||
}
|
||||
return labels
|
||||
}, [maxSeconds, gridSecondWidth])
|
||||
|
||||
// Piano keys
|
||||
const pitchRows = useMemo(() => {
|
||||
const rows = [] as Array<{ midi: number; isBlack: boolean; label: string; isC: boolean }>
|
||||
const black = new Set([1, 3, 6, 8, 10])
|
||||
for (let p = HIGH_NOTE; p >= LOW_NOTE; p -= 1) {
|
||||
const name = midiToName(p)
|
||||
const isC = p % 12 === 0
|
||||
rows.push({ midi: p, isBlack: black.has(p % 12), label: name, isC })
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
|
||||
// Detect overlapping notes using optimized sweep line algorithm
|
||||
const overlappingNoteIds = useMemo(() => {
|
||||
if (notes.length < 2) return new Set<string>()
|
||||
|
||||
const overlapping = new Set<string>()
|
||||
const sortedNotes = [...notes].sort((a, b) => a.start - b.start)
|
||||
const EPSILON = 0.05 // Tolerance for floating point comparison
|
||||
|
||||
// Use a sliding window approach - more efficient for typical music data
|
||||
// Active notes: notes that haven't ended yet
|
||||
const activeNotes: typeof sortedNotes = []
|
||||
|
||||
for (const note of sortedNotes) {
|
||||
// Remove notes that have ended before current note starts
|
||||
while (activeNotes.length > 0) {
|
||||
const firstActive = activeNotes[0]
|
||||
const firstActiveEnd = firstActive.start + firstActive.duration
|
||||
if (firstActiveEnd <= note.start + EPSILON) {
|
||||
activeNotes.shift()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check overlap with remaining active notes
|
||||
for (const activeNote of activeNotes) {
|
||||
const activeEnd = activeNote.start + activeNote.duration
|
||||
if (note.start < activeEnd - EPSILON) {
|
||||
overlapping.add(activeNote.id)
|
||||
overlapping.add(note.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Add current note to active set (maintain sorted order by end time)
|
||||
const noteEnd = note.start + note.duration
|
||||
let insertIndex = activeNotes.length
|
||||
for (let i = 0; i < activeNotes.length; i++) {
|
||||
const aEnd = activeNotes[i].start + activeNotes[i].duration
|
||||
if (noteEnd < aEnd) {
|
||||
insertIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
activeNotes.splice(insertIndex, 0, note)
|
||||
}
|
||||
return overlapping
|
||||
}, [notes])
|
||||
|
||||
// Calculate visible area with buffer for smooth scrolling
|
||||
const BUFFER_PX = 200 // Render notes slightly outside viewport for smooth scrolling
|
||||
const visibleArea = useMemo(() => {
|
||||
return {
|
||||
left: Math.max(0, scrollLeft - BUFFER_PX),
|
||||
right: scrollLeft + viewportWidth + BUFFER_PX,
|
||||
top: Math.max(0, scrollTop - BUFFER_PX),
|
||||
bottom: scrollTop + viewportHeight + BUFFER_PX,
|
||||
}
|
||||
}, [scrollLeft, scrollTop, viewportWidth, viewportHeight])
|
||||
|
||||
// Filter notes to only render visible ones (virtualization)
|
||||
const visibleNotes = useMemo(() => {
|
||||
return notes.filter(note => {
|
||||
const noteSeconds = beatToSeconds(note.start)
|
||||
const noteDurationSeconds = beatToSeconds(note.duration)
|
||||
const noteLeft = noteSeconds * gridSecondWidth
|
||||
const noteRight = noteLeft + noteDurationSeconds * gridSecondWidth
|
||||
const noteTop = (HIGH_NOTE - note.midi) * rowHeight
|
||||
const noteBottom = noteTop + rowHeight
|
||||
|
||||
// Check if note intersects with visible area
|
||||
const horizontallyVisible = noteRight >= visibleArea.left && noteLeft <= visibleArea.right
|
||||
const verticallyVisible = noteBottom >= visibleArea.top && noteTop <= visibleArea.bottom
|
||||
|
||||
return horizontallyVisible && verticallyVisible
|
||||
})
|
||||
}, [notes, visibleArea, gridSecondWidth, rowHeight, beatToSeconds])
|
||||
|
||||
// Calculate visible grid lines (virtualization)
|
||||
const visibleGridLines = useMemo(() => {
|
||||
const startSecond = Math.max(0, Math.floor(visibleArea.left / gridSecondWidth) - 1)
|
||||
const endSecond = Math.ceil(visibleArea.right / gridSecondWidth) + 1
|
||||
const startRow = Math.max(0, Math.floor(visibleArea.top / rowHeight) - 1)
|
||||
const endRow = Math.min(totalRows, Math.ceil(visibleArea.bottom / rowHeight) + 1)
|
||||
|
||||
return {
|
||||
horizontalLines: Array.from({ length: endRow - startRow + 1 }, (_, i) => startRow + i),
|
||||
verticalLines: Array.from({ length: endSecond - startSecond + 1 }, (_, i) => startSecond + i),
|
||||
}
|
||||
}, [visibleArea, gridSecondWidth, rowHeight, totalRows])
|
||||
|
||||
const playheadSeconds = beatToSeconds(playhead)
|
||||
|
||||
// Selection drag handlers
|
||||
const handleRulerPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!isSelectingRange) {
|
||||
// Normal click to seek
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left + (rulerScrollRef.current?.scrollLeft ?? 0)
|
||||
const seconds = x / gridSecondWidth
|
||||
onSeek(secondsToBeat(seconds))
|
||||
return
|
||||
}
|
||||
|
||||
// Start selection drag
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left + (rulerScrollRef.current?.scrollLeft ?? 0)
|
||||
const seconds = Math.max(0, x / gridSecondWidth)
|
||||
|
||||
selectionDragRef.current = {
|
||||
startX: event.clientX,
|
||||
startSeconds: seconds,
|
||||
}
|
||||
|
||||
onSelectionChange?.(seconds, seconds)
|
||||
|
||||
const handleSelectionMove = (e: PointerEvent) => {
|
||||
if (!selectionDragRef.current) return
|
||||
const currentX = e.clientX - rect.left + (rulerScrollRef.current?.scrollLeft ?? 0)
|
||||
const currentSeconds = Math.max(0, currentX / gridSecondWidth)
|
||||
const start = Math.min(selectionDragRef.current.startSeconds, currentSeconds)
|
||||
const end = Math.max(selectionDragRef.current.startSeconds, currentSeconds)
|
||||
onSelectionChange?.(start, end)
|
||||
}
|
||||
|
||||
const handleSelectionUp = () => {
|
||||
selectionDragRef.current = null
|
||||
window.removeEventListener('pointermove', handleSelectionMove)
|
||||
window.removeEventListener('pointerup', handleSelectionUp)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', handleSelectionMove)
|
||||
window.addEventListener('pointerup', handleSelectionUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="piano-shell">
|
||||
{/* Ruler */}
|
||||
<div className="ruler-shell">
|
||||
<div className="ruler-spacer" style={{ width: PITCH_WIDTH, flexShrink: 0 }} />
|
||||
<div
|
||||
ref={rulerScrollRef}
|
||||
className={`ruler-scroll ${isSelectingRange ? 'selecting' : ''}`}
|
||||
onPointerDown={handleRulerPointerDown}
|
||||
>
|
||||
<div className="ruler" style={{ width: contentWidth }}>
|
||||
{secondLabels.map((mark) => (
|
||||
<div key={mark.left} className="measure-mark" style={{ left: mark.left }}>
|
||||
<span>{mark.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Selection range indicator */}
|
||||
{selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart && (
|
||||
<div
|
||||
className="selection-range"
|
||||
style={{
|
||||
left: selectionStart * gridSecondWidth,
|
||||
width: (selectionEnd - selectionStart) * gridSecondWidth
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Ruler playhead indicator */}
|
||||
<div
|
||||
className="ruler-playhead"
|
||||
style={{ left: playheadSeconds * gridSecondWidth }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="roll-body">
|
||||
{/* Piano keys - synced with vertical scroll */}
|
||||
<div className="pitch-rail" style={{ width: PITCH_WIDTH }}>
|
||||
<div
|
||||
className="pitch-rail-inner"
|
||||
style={{
|
||||
transform: `translateY(${-scrollTop}px)`,
|
||||
height: contentHeight
|
||||
}}
|
||||
>
|
||||
{pitchRows.map((pitch) => (
|
||||
<div
|
||||
key={pitch.midi}
|
||||
className={`pitch-cell ${pitch.isBlack ? 'pitch-black' : 'pitch-white'} ${pitch.isC ? 'pitch-c' : ''}`}
|
||||
style={{ height: rowHeight, cursor: 'pointer' }}
|
||||
onClick={() => onPlayNote?.(pitch.midi)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<span className="pitch-label">{pitch.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable grid area */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="roll-grid"
|
||||
onDoubleClick={handleGridDoubleClick}
|
||||
>
|
||||
<div
|
||||
className="grid-content"
|
||||
style={{
|
||||
width: contentWidth,
|
||||
height: contentHeight,
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{/* SVG Grid - virtualized for performance */}
|
||||
<svg
|
||||
className="grid-svg"
|
||||
width={contentWidth}
|
||||
height={contentHeight}
|
||||
style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Horizontal lines (pitch rows) - only visible ones */}
|
||||
{visibleGridLines.horizontalLines.map(i => (
|
||||
<line
|
||||
key={`h-${i}`}
|
||||
x1={visibleArea.left}
|
||||
y1={i * rowHeight}
|
||||
x2={visibleArea.right}
|
||||
y2={i * rowHeight}
|
||||
stroke="var(--grid-line-minor)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
{/* Vertical lines (seconds) - only visible ones */}
|
||||
{visibleGridLines.verticalLines.map(i => (
|
||||
<line
|
||||
key={`v-${i}`}
|
||||
x1={i * gridSecondWidth}
|
||||
y1={visibleArea.top}
|
||||
x2={i * gridSecondWidth}
|
||||
y2={visibleArea.bottom}
|
||||
stroke="var(--grid-line-minor)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Selection range in grid */}
|
||||
{selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart && (
|
||||
<div
|
||||
className="grid-selection-range"
|
||||
style={{
|
||||
left: selectionStart * gridSecondWidth,
|
||||
width: (selectionEnd - selectionStart) * gridSecondWidth,
|
||||
height: contentHeight
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Playhead */}
|
||||
<div
|
||||
className="playhead"
|
||||
style={{
|
||||
left: playheadSeconds * gridSecondWidth,
|
||||
height: contentHeight
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Notes - virtualized: only render visible notes */}
|
||||
{visibleNotes.map((note) => {
|
||||
const noteSeconds = beatToSeconds(note.start)
|
||||
const noteDurationSeconds = beatToSeconds(note.duration)
|
||||
const left = noteSeconds * gridSecondWidth
|
||||
const top = (HIGH_NOTE - note.midi) * rowHeight
|
||||
const noteWidthPx = Math.max(noteDurationSeconds * gridSecondWidth, 4)
|
||||
const noteHeight = rowHeight - 2
|
||||
const isOverlapping = overlappingNoteIds.has(note.id)
|
||||
// Dynamic font size based on row height (base: 12px at 20px row height)
|
||||
const fontSize = Math.max(10, Math.min(24, rowHeight * 0.6))
|
||||
|
||||
return (
|
||||
<NoteChip
|
||||
key={note.id}
|
||||
note={note}
|
||||
left={left}
|
||||
top={top}
|
||||
width={noteWidthPx}
|
||||
height={noteHeight}
|
||||
fontSize={fontSize}
|
||||
isSelected={selectedId === note.id}
|
||||
isOverlapping={isOverlapping}
|
||||
onPointerDown={(event, mode) => startDrag(event, note, mode)}
|
||||
onDoubleClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onFocusLyric?.(note.id)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Base values used for scaling; actual runtime values are derived in components
|
||||
export const BASE_GRID_SECOND_WIDTH = 80
|
||||
export const BASE_ROW_HEIGHT = 20
|
||||
export const PITCH_WIDTH = 60
|
||||
// C-1 to C8 range (MIDI note numbers)
|
||||
// LOW_NOTE = 0 to support SP markers (pitch=0) in some MIDI files
|
||||
export const LOW_NOTE = 0 // C-1 (also supports pitch=0 for SP markers)
|
||||
export const HIGH_NOTE = 108 // C8
|
||||
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: 'Space Grotesk', 'IBM Plex Sans', system-ui, sans-serif;
|
||||
color: var(--text-primary);
|
||||
background: radial-gradient(circle at 20% 20%, rgba(72, 228, 194, 0.08), transparent 35%),
|
||||
radial-gradient(circle at 80% 0%, rgba(75, 100, 188, 0.24), transparent 40%),
|
||||
#0f1528;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
background: radial-gradient(circle at 20% 20%, rgba(63, 140, 255, 0.08), transparent 35%),
|
||||
radial-gradient(circle at 80% 0%, rgba(75, 100, 188, 0.14), transparent 40%),
|
||||
#f5f7fb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Midi } from '@tonejs/midi'
|
||||
import { writeMidi } from 'midi-file'
|
||||
import type { MidiData, MidiEvent } from 'midi-file'
|
||||
import type { NoteEvent, ProjectSnapshot, TimeSignature } from '../types'
|
||||
|
||||
const DEFAULT_SIGNATURE: TimeSignature = [4, 4]
|
||||
|
||||
// Decode UTF-8 byte string (latin1 encoded) to proper Unicode string
|
||||
// This matches: text.encode("latin1").decode("utf-8") in Python
|
||||
function decodeUtf8ByteString(byteString: string): string {
|
||||
try {
|
||||
const bytes = new Uint8Array(byteString.length)
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
bytes[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
return new TextDecoder('utf-8').decode(bytes)
|
||||
} catch {
|
||||
return byteString
|
||||
}
|
||||
}
|
||||
|
||||
// Encode Unicode string to UTF-8 byte string (latin1 encoding)
|
||||
// This matches: text.encode("utf-8").decode("latin1") in Python
|
||||
function encodeUtf8ByteString(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
let output = ''
|
||||
bytes.forEach((b) => {
|
||||
output += String.fromCharCode(b)
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
export async function importMidiFile(file: File): Promise<ProjectSnapshot> {
|
||||
const buffer = await file.arrayBuffer()
|
||||
return parseMidiBuffer(buffer)
|
||||
}
|
||||
|
||||
export async function parseMidiBuffer(buffer: ArrayBuffer): Promise<ProjectSnapshot> {
|
||||
const midi = new Midi(buffer)
|
||||
const tempo = midi.header.tempos[0]?.bpm ?? 120
|
||||
const timeSignature = (midi.header.timeSignatures[0]?.timeSignature as TimeSignature | undefined) ?? DEFAULT_SIGNATURE
|
||||
|
||||
// Merge notes from all tracks and sort by ticks then by midi (for stable ordering)
|
||||
const allNotes = midi.tracks
|
||||
.flatMap(t => t.notes)
|
||||
.sort((a, b) => a.ticks - b.ticks || a.midi - b.midi)
|
||||
|
||||
// Get lyrics from header.meta and sort by ticks
|
||||
const lyricEvents = midi.header.meta
|
||||
.filter((event) => event.type === 'lyrics')
|
||||
.sort((a, b) => a.ticks - b.ticks)
|
||||
|
||||
// Match lyrics to notes by tick position
|
||||
// Each lyric should be consumed by exactly one note at the same tick
|
||||
const lyricsByTick = new Map<number, string[]>()
|
||||
for (const event of lyricEvents) {
|
||||
const existing = lyricsByTick.get(event.ticks) || []
|
||||
existing.push(decodeUtf8ByteString(event.text))
|
||||
lyricsByTick.set(event.ticks, existing)
|
||||
}
|
||||
|
||||
// Track which lyrics have been used at each tick position
|
||||
const usedLyricIndices = new Map<number, number>()
|
||||
|
||||
const notes: NoteEvent[] = allNotes.map((note, index) => {
|
||||
const beat = note.ticks / midi.header.ppq
|
||||
const durationBeats = note.durationTicks / midi.header.ppq
|
||||
|
||||
let lyric = ''
|
||||
|
||||
// First try exact tick match
|
||||
const lyricsAtTick = lyricsByTick.get(note.ticks)
|
||||
if (lyricsAtTick && lyricsAtTick.length > 0) {
|
||||
const usedIndex = usedLyricIndices.get(note.ticks) || 0
|
||||
if (usedIndex < lyricsAtTick.length) {
|
||||
lyric = lyricsAtTick[usedIndex]
|
||||
usedLyricIndices.set(note.ticks, usedIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, try nearby ticks (within small tolerance)
|
||||
if (!lyric) {
|
||||
const tolerance = midi.header.ppq / 100 // Very small tolerance
|
||||
for (const [tick, lyrics] of lyricsByTick.entries()) {
|
||||
if (Math.abs(tick - note.ticks) <= tolerance) {
|
||||
const usedIndex = usedLyricIndices.get(tick) || 0
|
||||
if (usedIndex < lyrics.length) {
|
||||
lyric = lyrics[usedIndex]
|
||||
usedLyricIndices.set(tick, usedIndex + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${index}-${note.midi}-${Math.round(note.ticks)}`,
|
||||
midi: note.midi,
|
||||
start: beat,
|
||||
duration: Math.max(durationBeats, 0.0625),
|
||||
velocity: note.velocity,
|
||||
lyric,
|
||||
}
|
||||
})
|
||||
|
||||
return { tempo, timeSignature, notes, ppq: midi.header.ppq }
|
||||
}
|
||||
|
||||
// Used to add absoluteTime property for sorting
|
||||
type WithAbsoluteTime<T> = T & { absoluteTime: number }
|
||||
|
||||
export function exportMidi(snapshot: ProjectSnapshot): Blob {
|
||||
const ppq = snapshot.ppq ?? 480 // Use original ppq if available, otherwise default to 480
|
||||
const microsecondsPerBeat = Math.round(60000000 / snapshot.tempo) // Convert BPM to microseconds per beat
|
||||
|
||||
// Sort notes by start time, then by midi for stable ordering
|
||||
const sortedNotes = [...snapshot.notes].sort((a, b) => a.start - b.start || a.midi - b.midi)
|
||||
|
||||
// Build events for a single track containing both lyrics and notes
|
||||
// Event order at same tick: note_off (0) < lyrics (1) < note_on (2)
|
||||
// This matches meta.py's tg2midi implementation
|
||||
const events: Array<WithAbsoluteTime<MidiEvent>> = []
|
||||
|
||||
// Add all note events and their corresponding lyrics
|
||||
sortedNotes.forEach((note) => {
|
||||
const startTicks = Math.round(note.start * ppq)
|
||||
const endTicks = Math.round((note.start + note.duration) * ppq)
|
||||
const velocity = Math.round(note.velocity * 127)
|
||||
|
||||
// Add lyric event at the same tick as note_on (but will be sorted before it)
|
||||
const lyricText = note.lyric ?? ''
|
||||
const encodedLyric = encodeUtf8ByteString(lyricText)
|
||||
|
||||
// Lyric event - sort key 1 (after note_off, before note_on)
|
||||
events.push({
|
||||
absoluteTime: startTicks,
|
||||
deltaTime: 0,
|
||||
meta: true,
|
||||
type: 'lyrics',
|
||||
text: encodedLyric,
|
||||
_sortKey: 1,
|
||||
} as WithAbsoluteTime<MidiEvent> & { _sortKey: number })
|
||||
|
||||
// Note on event - sort key 2 (after lyrics)
|
||||
events.push({
|
||||
absoluteTime: startTicks,
|
||||
deltaTime: 0,
|
||||
type: 'noteOn',
|
||||
channel: 0,
|
||||
noteNumber: note.midi,
|
||||
velocity: velocity,
|
||||
_sortKey: 2,
|
||||
} as WithAbsoluteTime<MidiEvent> & { _sortKey: number })
|
||||
|
||||
// Note off event - sort key 0 (before everything at same tick)
|
||||
events.push({
|
||||
absoluteTime: endTicks,
|
||||
deltaTime: 0,
|
||||
type: 'noteOff',
|
||||
channel: 0,
|
||||
noteNumber: note.midi,
|
||||
velocity: 0,
|
||||
_sortKey: 0,
|
||||
} as WithAbsoluteTime<MidiEvent> & { _sortKey: number })
|
||||
})
|
||||
|
||||
// Sort events by absoluteTime, then by _sortKey
|
||||
events.sort((a, b) => {
|
||||
const aKey = (a as { _sortKey?: number })._sortKey ?? 1
|
||||
const bKey = (b as { _sortKey?: number })._sortKey ?? 1
|
||||
return a.absoluteTime - b.absoluteTime || aKey - bKey
|
||||
})
|
||||
|
||||
// Convert absolute time to delta time
|
||||
let lastTick = 0
|
||||
events.forEach(event => {
|
||||
event.deltaTime = event.absoluteTime - lastTick
|
||||
lastTick = event.absoluteTime
|
||||
delete (event as { absoluteTime?: number }).absoluteTime
|
||||
delete (event as { _sortKey?: number })._sortKey
|
||||
})
|
||||
|
||||
// Build the MIDI track with header events
|
||||
const track: MidiEvent[] = [
|
||||
// Set tempo
|
||||
{
|
||||
deltaTime: 0,
|
||||
meta: true,
|
||||
type: 'setTempo',
|
||||
microsecondsPerBeat: microsecondsPerBeat,
|
||||
},
|
||||
// Time signature
|
||||
{
|
||||
deltaTime: 0,
|
||||
meta: true,
|
||||
type: 'timeSignature',
|
||||
numerator: snapshot.timeSignature[0],
|
||||
denominator: snapshot.timeSignature[1],
|
||||
metronome: 24,
|
||||
thirtyseconds: 8,
|
||||
},
|
||||
// All note and lyric events
|
||||
...events,
|
||||
// End of track
|
||||
{
|
||||
deltaTime: 0,
|
||||
meta: true,
|
||||
type: 'endOfTrack',
|
||||
},
|
||||
]
|
||||
|
||||
// Build MIDI data structure
|
||||
const midiData: MidiData = {
|
||||
header: {
|
||||
format: 0, // Single track format (type 0)
|
||||
numTracks: 1,
|
||||
ticksPerBeat: ppq,
|
||||
},
|
||||
tracks: [track],
|
||||
}
|
||||
|
||||
const bytes = writeMidi(midiData)
|
||||
return new Blob([new Uint8Array(bytes)], { type: 'audio/midi' })
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { create } from 'zustand'
|
||||
import type { NoteEvent, TimeSignature } from '../types'
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max)
|
||||
|
||||
export type MidiStore = {
|
||||
tempo: number
|
||||
timeSignature: TimeSignature
|
||||
notes: NoteEvent[]
|
||||
selectedId: string | null
|
||||
playhead: number
|
||||
ppq: number | undefined // Ticks per quarter note (for preserving original MIDI timing)
|
||||
addNote: (partial?: Partial<NoteEvent>) => NoteEvent
|
||||
updateNote: (id: string, partial: Partial<NoteEvent>) => void
|
||||
removeNote: (id: string) => void
|
||||
setNotes: (notes: NoteEvent[]) => void
|
||||
setTempo: (tempo: number) => void
|
||||
setTimeSignature: (sig: TimeSignature) => void
|
||||
setPpq: (ppq: number | undefined) => void
|
||||
select: (id: string | null) => void
|
||||
setLyric: (id: string, lyric: string) => void
|
||||
setPlayhead: (beat: number) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
const defaultNotes: NoteEvent[] = [
|
||||
{ id: nanoid(), midi: 64, start: 0, duration: 1.5, velocity: 0.9, lyric: 'la' },
|
||||
{ id: nanoid(), midi: 67, start: 1.5, duration: 1.5, velocity: 0.85, lyric: 'na' },
|
||||
{ id: nanoid(), midi: 69, start: 3, duration: 2, velocity: 0.8, lyric: 'ah' },
|
||||
]
|
||||
|
||||
export const useMidiStore = create<MidiStore>((set) => ({
|
||||
tempo: 110,
|
||||
timeSignature: [4, 4],
|
||||
notes: defaultNotes,
|
||||
selectedId: null,
|
||||
playhead: 0,
|
||||
ppq: undefined,
|
||||
addNote: (partial = {}) => {
|
||||
const note: NoteEvent = {
|
||||
id: nanoid(),
|
||||
midi: partial.midi ?? 64,
|
||||
start: partial.start ?? 0,
|
||||
duration: partial.duration ?? 1,
|
||||
velocity: clamp(partial.velocity ?? 0.85, 0, 1),
|
||||
lyric: partial.lyric ?? '',
|
||||
}
|
||||
set((state) => ({ notes: [...state.notes, note] }))
|
||||
return note
|
||||
},
|
||||
updateNote: (id, partial) => {
|
||||
set((state) => ({
|
||||
notes: state.notes.map((note) =>
|
||||
note.id === id
|
||||
? {
|
||||
...note,
|
||||
...partial,
|
||||
duration: Math.max(partial.duration ?? note.duration, 0.0625),
|
||||
}
|
||||
: note,
|
||||
),
|
||||
}))
|
||||
},
|
||||
removeNote: (id) => set((state) => ({ notes: state.notes.filter((n) => n.id !== id) })),
|
||||
setNotes: (notes) => set(() => ({ notes })),
|
||||
setTempo: (tempo) => set(() => ({ tempo: clamp(tempo, 30, 240) })),
|
||||
setTimeSignature: (sig) => set(() => ({ timeSignature: sig })),
|
||||
setPpq: (ppq) => set(() => ({ ppq })),
|
||||
select: (id) => set(() => ({ selectedId: id })),
|
||||
setLyric: (id, lyric) =>
|
||||
set((state) => ({
|
||||
notes: state.notes.map((note) => (note.id === id ? { ...note, lyric } : note)),
|
||||
})),
|
||||
setPlayhead: (beat) => set(() => ({ playhead: Math.max(beat, 0) })),
|
||||
clear: () => set(() => ({ notes: [], selectedId: null })),
|
||||
}))
|
||||
@@ -0,0 +1,17 @@
|
||||
export type NoteEvent = {
|
||||
id: string
|
||||
midi: number
|
||||
start: number // in beats
|
||||
duration: number // in beats
|
||||
velocity: number
|
||||
lyric: string
|
||||
}
|
||||
|
||||
export type TimeSignature = [number, number]
|
||||
|
||||
export type ProjectSnapshot = {
|
||||
tempo: number
|
||||
timeSignature: TimeSignature
|
||||
notes: NoteEvent[]
|
||||
ppq?: number // Ticks per quarter note (for preserving original MIDI timing)
|
||||
}
|
||||
Reference in New Issue
Block a user