Skip to content

Commit 00deeac

Browse files
Y-RyuZUclaude
andcommitted
feat: unify track instrument UI with pitch offset controls
- Remove global instrument selector; all instrument config is per-track - Auto-open Tracks panel when any MIDI is loaded - Remove "Global Sample" option; default instrument is now pling - Add per-track pitch offset with -/+ buttons (semitone transpose) - Auto-apply default instruments on MIDI load (no manual OGG needed) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1c94d70 commit 00deeac

4 files changed

Lines changed: 60 additions & 149 deletions

File tree

src/components/features/noteblock/NoteBlockPlayer.tsx

Lines changed: 8 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,8 @@ import { Play, Pause, Download, Settings, Music } from 'lucide-react';
1212
import { useNoteBlockAudio } from './useNoteBlockAudio';
1313
import { useVisualizer } from './useVisualizer';
1414
import { presets } from './presets';
15-
import { INSTRUMENT_PRESETS } from './instrumentPresets';
1615
import TrackInstrumentPanel from './TrackInstrumentPanel';
1716

18-
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
19-
const BASE_NOTES: string[] = [];
20-
for (let octave = 2; octave <= 6; octave++) {
21-
for (const name of NOTE_NAMES) {
22-
BASE_NOTES.push(`${name}${octave}`);
23-
}
24-
}
25-
2617
function formatTime(seconds: number): string {
2718
const m = Math.floor(seconds / 60);
2819
const s = Math.floor(seconds % 60);
@@ -35,12 +26,9 @@ export default function NoteBlockPlayer() {
3526

3627
const vizContainerRef = useRef<HTMLDivElement>(null);
3728
const vizInitializedRef = useRef(false);
38-
const oggInputRef = useRef<HTMLInputElement>(null);
3929
const midiInputRef = useRef<HTMLInputElement>(null);
4030
const bgInputRef = useRef<HTMLInputElement>(null);
4131

42-
const [baseNote, setBaseNote] = useState('F#4');
43-
const [globalInstrument, setGlobalInstrument] = useState('harp');
4432
const [presetIndex, setPresetIndex] = useState('0');
4533
const [settingsOpen, setSettingsOpen] = useState(false);
4634
const [tracksOpen, setTracksOpen] = useState(false);
@@ -108,51 +96,12 @@ export default function NoteBlockPlayer() {
10896
}
10997
}, [applyDetailOptions]);
11098

111-
// Auto-load harp as default global instrument on mount
99+
// Auto-open tracks panel when MIDI is loaded
112100
useEffect(() => {
113-
const loadDefault = async () => {
114-
try {
115-
const preset = INSTRUMENT_PRESETS.find(p => p.id === 'harp');
116-
if (!preset) return;
117-
const res = await fetch(preset.oggUrl);
118-
const blob = await res.blob();
119-
const file = new File([blob], 'harp.ogg', { type: 'audio/ogg' });
120-
await audio.loadSample(file, preset.baseNote);
121-
setBaseNote(preset.baseNote);
122-
} catch { /* silent */ }
123-
};
124-
loadDefault();
125-
// eslint-disable-next-line react-hooks/exhaustive-deps
126-
}, []);
127-
128-
const handleGlobalInstrumentChange = async (value: string) => {
129-
setGlobalInstrument(value);
130-
if (value === 'custom') return;
131-
const preset = INSTRUMENT_PRESETS.find(p => p.id === value);
132-
if (!preset) return;
133-
try {
134-
const res = await fetch(preset.oggUrl);
135-
const blob = await res.blob();
136-
const file = new File([blob], `${preset.id}.ogg`, { type: 'audio/ogg' });
137-
await ensureVisualizer();
138-
await audio.loadSample(file, preset.baseNote);
139-
setBaseNote(preset.baseNote);
140-
} catch { /* silent */ }
141-
};
142-
143-
const handleOggChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
144-
const file = e.target.files?.[0];
145-
if (!file) return;
146-
setGlobalInstrument('custom');
147-
await ensureVisualizer();
148-
await audio.loadSample(file, baseNote);
149-
};
150-
151-
const handleBaseNoteChange = async (value: string) => {
152-
setBaseNote(value);
153-
await ensureVisualizer();
154-
await audio.reloadSample(value);
155-
};
101+
if (audio.trackInfos.length > 0) {
102+
setTracksOpen(true);
103+
}
104+
}, [audio.trackInfos]);
156105

157106
const handleMidiChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
158107
const file = e.target.files?.[0];
@@ -188,13 +137,11 @@ export default function NoteBlockPlayer() {
188137
audio.seekTo(value[0]);
189138
};
190139

191-
const hasTrackSamplers = audio.trackAssignments.some(a => !a.muted && a.instrumentId !== 'global');
192-
const ready = (audio.sampleLoaded || hasTrackSamplers) && audio.midiLoaded;
140+
const ready = audio.midiLoaded;
193141

194142
return (
195143
<div className="flex flex-col h-full gap-2">
196144
{/* Hidden file inputs */}
197-
<input ref={oggInputRef} type="file" accept=".ogg,audio/*" onChange={handleOggChange} className="hidden" />
198145
<input ref={midiInputRef} type="file" accept=".mid,.midi" onChange={handleMidiChange} className="hidden" />
199146
<input ref={bgInputRef} type="file" accept="image/*" onChange={handleBgChange} className="hidden" />
200147

@@ -257,45 +204,6 @@ export default function NoteBlockPlayer() {
257204

258205
<div className="w-px h-4 bg-white/20" />
259206

260-
{/* Global instrument selector */}
261-
<Select value={globalInstrument} onValueChange={handleGlobalInstrumentChange}>
262-
<SelectTrigger className="h-7 w-28 text-[11px] bg-transparent border-white/20 text-gray-100">
263-
<SelectValue />
264-
</SelectTrigger>
265-
<SelectContent>
266-
{INSTRUMENT_PRESETS.map(p => (
267-
<SelectItem key={p.id} value={p.id} className="text-xs">{p.name}</SelectItem>
268-
))}
269-
<SelectItem value="custom" className="text-xs">Custom OGG...</SelectItem>
270-
</SelectContent>
271-
</Select>
272-
273-
{globalInstrument === 'custom' && (
274-
<Button
275-
variant="ghost"
276-
size="sm"
277-
className="h-7 px-2 text-[11px] text-gray-200 hover:text-white hover:bg-white/15"
278-
onClick={() => oggInputRef.current?.click()}
279-
>
280-
OGG {audio.sampleLoaded && <span className="w-1.5 h-1.5 rounded-full bg-green-400 ml-1" />}
281-
</Button>
282-
)}
283-
284-
{globalInstrument === 'custom' && (
285-
<Select value={baseNote} onValueChange={handleBaseNoteChange}>
286-
<SelectTrigger className="h-7 w-16 text-[11px] bg-transparent border-white/20 text-gray-100">
287-
<SelectValue />
288-
</SelectTrigger>
289-
<SelectContent>
290-
{BASE_NOTES.map(note => (
291-
<SelectItem key={note} value={note} className="text-xs">{note}</SelectItem>
292-
))}
293-
</SelectContent>
294-
</Select>
295-
)}
296-
297-
<div className="w-px h-4 bg-white/20" />
298-
299207
<Button
300208
variant="ghost"
301209
size="sm"
@@ -314,7 +222,7 @@ export default function NoteBlockPlayer() {
314222
BG
315223
</Button>
316224

317-
{audio.trackInfos.length > 0 && (
225+
{audio.midiLoaded && (
318226
<>
319227
<div className="w-px h-4 bg-white/20" />
320228
<Button
@@ -355,7 +263,7 @@ export default function NoteBlockPlayer() {
355263
</div>
356264

357265
{/* Track instrument panel */}
358-
{tracksOpen && audio.trackInfos.length > 0 && (
266+
{tracksOpen && audio.midiLoaded && (
359267
<div className="p-2 rounded border border-white/20 bg-black/40 backdrop-blur-sm">
360268
<TrackInstrumentPanel
361269
trackInfos={audio.trackInfos}

src/components/features/noteblock/TrackInstrumentPanel.tsx

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,10 @@ import { Checkbox } from '@/components/ui/checkbox';
66
import {
77
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
88
} from '@/components/ui/select';
9+
import { Minus, Plus } from 'lucide-react';
910
import type { TrackInfo, TrackAssignment } from './types';
1011
import { INSTRUMENT_PRESETS } from './instrumentPresets';
1112

12-
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
13-
const BASE_NOTES: string[] = [];
14-
for (let octave = 2; octave <= 6; octave++) {
15-
for (const name of NOTE_NAMES) {
16-
BASE_NOTES.push(`${name}${octave}`);
17-
}
18-
}
19-
2013
interface TrackInstrumentPanelProps {
2114
trackInfos: TrackInfo[];
2215
trackAssignments: TrackAssignment[];
@@ -39,14 +32,13 @@ export default function TrackInstrumentPanel({
3932
const next = prev.map(a =>
4033
a.trackIndex === trackIndex ? { ...a, ...patch } : a
4134
);
42-
// Defer onApply to avoid setState-during-render warning
4335
queueMicrotask(() => onApply(next));
4436
return next;
4537
});
4638
}, [onApply]);
4739

4840
return (
49-
<div className="space-y-1">
41+
<div className="space-y-1.5">
5042
{trackInfos.map(track => {
5143
const assignment = draft.find(a => a.trackIndex === track.index);
5244
if (!assignment) return null;
@@ -66,6 +58,7 @@ export default function TrackInstrumentPanel({
6658
{track.noteCount}n
6759
</span>
6860

61+
{/* Instrument selector */}
6962
<Select
7063
value={assignment.instrumentId}
7164
onValueChange={(v) => handleChange(track.index, {
@@ -77,14 +70,14 @@ export default function TrackInstrumentPanel({
7770
<SelectValue />
7871
</SelectTrigger>
7972
<SelectContent>
80-
<SelectItem value="global" className="text-xs">Global Sample</SelectItem>
81-
<SelectItem value="custom" className="text-xs">Custom OGG...</SelectItem>
8273
{INSTRUMENT_PRESETS.map(p => (
8374
<SelectItem key={p.id} value={p.id} className="text-xs">{p.name}</SelectItem>
8475
))}
76+
<SelectItem value="custom" className="text-xs">Custom OGG...</SelectItem>
8577
</SelectContent>
8678
</Select>
8779

80+
{/* Custom OGG upload */}
8881
{assignment.instrumentId === 'custom' && (
8982
<>
9083
<input
@@ -108,21 +101,28 @@ export default function TrackInstrumentPanel({
108101
</>
109102
)}
110103

111-
{(assignment.instrumentId === 'custom' || assignment.instrumentId === 'global') && (
112-
<Select
113-
value={assignment.baseNote ?? 'F#4'}
114-
onValueChange={(v) => handleChange(track.index, { baseNote: v })}
104+
{/* Pitch offset: - [value] + */}
105+
<div className="flex items-center gap-0">
106+
<Button
107+
variant="ghost"
108+
size="sm"
109+
className="h-5 w-5 p-0 text-gray-300 hover:text-white hover:bg-white/15"
110+
onClick={() => handleChange(track.index, { pitchOffset: (assignment.pitchOffset ?? 0) - 1 })}
115111
>
116-
<SelectTrigger className="h-6 w-16 text-[11px] bg-transparent border-white/20 text-gray-100">
117-
<SelectValue />
118-
</SelectTrigger>
119-
<SelectContent>
120-
{BASE_NOTES.map(note => (
121-
<SelectItem key={note} value={note} className="text-xs">{note}</SelectItem>
122-
))}
123-
</SelectContent>
124-
</Select>
125-
)}
112+
<Minus className="w-3 h-3" />
113+
</Button>
114+
<span className="text-[11px] text-gray-100 w-8 text-center tabular-nums font-mono">
115+
{(assignment.pitchOffset ?? 0) >= 0 ? '+' : ''}{assignment.pitchOffset ?? 0}
116+
</span>
117+
<Button
118+
variant="ghost"
119+
size="sm"
120+
className="h-5 w-5 p-0 text-gray-300 hover:text-white hover:bg-white/15"
121+
onClick={() => handleChange(track.index, { pitchOffset: (assignment.pitchOffset ?? 0) + 1 })}
122+
>
123+
<Plus className="w-3 h-3" />
124+
</Button>
125+
</div>
126126
</div>
127127
);
128128
})}

src/components/features/noteblock/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ export interface TrackAssignment {
1818
instrumentId: string;
1919
customOggFile?: File;
2020
baseNote?: string;
21+
pitchOffset: number;
2122
muted: boolean;
2223
}

src/components/features/noteblock/useNoteBlockAudio.ts

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ import * as Tone from 'tone';
55
import { Midi } from '@tonejs/midi';
66
import type { TrackInfo, TrackAssignment } from './types';
77
import { getPresetById } from './instrumentPresets';
8+
import { Frequency } from 'tone';
9+
10+
function transposeName(noteName: string, semitones: number): string {
11+
if (semitones === 0) return noteName;
12+
const midi = Frequency(noteName).toMidi();
13+
return Frequency(midi + semitones, 'midi').toNote();
14+
}
815

916
export function useNoteBlockAudio() {
1017
const [sampleLoaded, setSampleLoaded] = useState(false);
@@ -131,30 +138,24 @@ export function useNoteBlockAudio() {
131138

132139
setTrackInfos(infos);
133140

134-
// Default assignments: all tracks use 'harp'
141+
// Default assignments: all tracks use 'pling'
135142
const defaultAssignments: TrackAssignment[] = infos.map(t => ({
136143
trackIndex: t.index,
137-
instrumentId: sampleLoaded ? 'global' : 'harp',
144+
instrumentId: 'pling',
145+
pitchOffset: 0,
138146
muted: false,
139147
}));
140148
setTrackAssignments(defaultAssignments);
141-
142-
// Schedule all notes using global sampler (initial playback)
143-
for (const track of midi.tracks) {
144-
for (const note of track.notes) {
145-
const id = transport.schedule((time) => {
146-
samplerRef.current?.triggerAttackRelease(note.name, note.duration, time, note.velocity);
147-
}, note.time);
148-
scheduledEventsRef.current.push(id);
149-
}
150-
}
151-
152149
setMidiDuration(midi.duration);
153150
setMidiLoaded(true);
151+
152+
// Auto-apply default assignments (loads pling sampler for all tracks)
153+
await applyTrackAssignmentsInternal(defaultAssignments);
154+
154155
return midi.duration;
155-
}, [clearSchedule, disposeTrackSamplers, sampleLoaded]);
156+
}, [clearSchedule, disposeTrackSamplers]);
156157

157-
const applyTrackAssignments = useCallback(async (assignments: TrackAssignment[]) => {
158+
const applyTrackAssignmentsInternal = useCallback(async (assignments: TrackAssignment[]) => {
158159
if (!midiRef.current) return;
159160

160161
setIsLoadingInstruments(true);
@@ -182,12 +183,7 @@ export function useNoteBlockAudio() {
182183
let url: string;
183184
let baseNote: string;
184185

185-
if (assignment.instrumentId === 'global') {
186-
if (!blobUrlRef.current) continue;
187-
cacheKey = `global-${assignment.baseNote ?? 'default'}`;
188-
url = blobUrlRef.current;
189-
baseNote = assignment.baseNote ?? 'F#4';
190-
} else if (assignment.instrumentId === 'custom' && assignment.customOggFile) {
186+
if (assignment.instrumentId === 'custom' && assignment.customOggFile) {
191187
cacheKey = `custom-${assignment.trackIndex}`;
192188
url = URL.createObjectURL(assignment.customOggFile);
193189
customBlobUrlsRef.current.add(url);
@@ -213,11 +209,13 @@ export function useNoteBlockAudio() {
213209
}
214210
trackSamplersRef.current.set(assignment.trackIndex, sampler);
215211

216-
// Schedule this track's notes to its sampler
212+
// Schedule this track's notes to its sampler (with pitch offset)
213+
const offset = assignment.pitchOffset ?? 0;
217214
for (const note of track.notes) {
218215
const s = sampler;
216+
const transposed = transposeName(note.name, offset);
219217
const id = transport.schedule((time) => {
220-
s.triggerAttackRelease(note.name, note.duration, time, note.velocity);
218+
s.triggerAttackRelease(transposed, note.duration, time, note.velocity);
221219
}, note.time);
222220
scheduledEventsRef.current.push(id);
223221
}
@@ -231,6 +229,10 @@ export function useNoteBlockAudio() {
231229
}
232230
}, [clearSchedule, disposeTrackSamplers]);
233231

232+
const applyTrackAssignments = useCallback(async (assignments: TrackAssignment[]) => {
233+
await applyTrackAssignmentsInternal(assignments);
234+
}, [applyTrackAssignmentsInternal]);
235+
234236
const loadMidiFromUrl = useCallback(async (url: string) => {
235237
const response = await fetch(url);
236238
const buffer = await response.arrayBuffer();

0 commit comments

Comments
 (0)