1import { useRef } from 'react';
2import { store, useApp } from '../state/store.ts';
3import { getInstrument, listInstruments } from '../instruments/registry.ts';
4import type { KnobSpec } from '../instruments/types.ts';
5import { importInstrumentText, listImportedIds, removeImportedInstrument } from '../instruments/numbl/adapter.ts';
6import { trackColor } from './colors.ts';
8// Imported instrument bundles: a file picker that accepts exported
9// numbl-instrument-v1 JSON, and a chip per import so a stale one can be
10// removed (a shadowed catalog instrument of the same id comes back).
11function ImportedInstruments() {
12 const fileRef = useRef<HTMLInputElement>(null);
13 const imported = listImportedIds();
14 const onFiles = async (files: FileList | null) => {
15 if (!files) return;
16 for (const f of files) {
17 try {
18 const bundle = await importInstrumentText(await f.text());
19 store.setStatus(`imported instrument '${bundle.name}'`);
20 } catch (err) {
21 store.setStatus(err instanceof Error ? err.message : String(err));
22 }
23 }
24 store.bumpInstruments();
25 };
26 return (
27 <div className="importinstr">
28 <button
29 className="addtrack"
30 title="Import an exported instrument bundle (numbl-instrument-v1 JSON)"
31 onClick={() => fileRef.current?.click()}
32 >+ Import instrument</button>
33 <input
34 ref={fileRef}
35 type="file"
36 accept=".json,application/json"
37 multiple
38 style={{ display: 'none' }}
39 onChange={(e) => { void onFiles(e.target.files); e.target.value = ''; }}
40 />
41 {imported.length > 0 && (
42 <div className="importedlist">
43 {imported.map((id) => (
44 <span key={id} className="importedchip">
45 {id}
46 <button
47 className="mini danger"
48 title="Remove imported instrument"
49 onClick={() => { void removeImportedInstrument(id).then(() => store.bumpInstruments()); }}
50 >✕</button>
51 </span>
52 ))}
53 </div>
54 )}
55 </div>
56 );
57}
59function fmtKnob(v: number): string {
60 const a = Math.abs(v);
61 if (a >= 1e5 || (a > 0 && a < 0.01)) return v.toExponential(1);
62 return String(Number(v.toPrecision(3)));
63}
65function KnobSlider({ knob, value, onChange }: { knob: KnobSpec; value: number | undefined; onChange: (v: number) => void }) {
66 const cur = value ?? knob.default;
67 const log = knob.scale === 'log';
68 const pos = log ? (1000 * Math.log(cur / knob.min)) / Math.log(knob.max / knob.min) : cur;
69 return (
70 <label className="knob" title={`${knob.label} (default ${fmtKnob(knob.default)})`}>
71 <span className="knoblabel">{knob.label}</span>
72 <input
73 type="range"
74 min={log ? 0 : knob.min}
75 max={log ? 1000 : knob.max}
76 step={log ? 1 : (knob.step ?? (knob.max - knob.min) / 200)}
77 value={pos}
78 onChange={(e) => {
79 const raw = Number(e.target.value);
80 onChange(log ? knob.min * Math.pow(knob.max / knob.min, raw / 1000) : raw);
81 }}
82 onClick={(e) => e.stopPropagation()}
83 />
84 <span className="knobvalue">{fmtKnob(cur)}</span>
85 </label>
86 );
87}
89export function TrackList() {
90 const app = useApp();
91 const song = app.rec?.song ?? null;
92 if (!song) return <div className="tracklist" />;
93 const instruments = listInstruments();
94 return (
95 <div className="tracklist">
96 {song.tracks.map((t, i) => {
97 const active = t.id === app.activeTrackId;
98 const armed = t.id === app.armedTrackId;
99 return (
100 <div key={t.id} className={`trackrow ${active ? 'active' : ''}`} onClick={() => store.setActiveTrack(t.id)}>
101 <div className="trackrow-top">
102 <span className="chip" style={{ background: trackColor(i) }} />
103 <input
104 className="trackname"
105 value={t.name}
106 onChange={(e) => store.updateTrack(t.id, { name: e.target.value }, false)}
107 onBlur={() => store.endEdit()}
108 onClick={(e) => e.stopPropagation()}
109 />
110 <button
111 className="mini danger"
112 title="Delete track"
113 onClick={(e) => { e.stopPropagation(); store.removeTrack(t.id); }}
114 >✕</button>
115 </div>
116 <div className="trackrow-bottom">
117 <select
118 value={t.instrumentId}
119 onChange={(e) => store.updateTrack(t.id, { instrumentId: e.target.value })}
120 onClick={(e) => e.stopPropagation()}
121 title="Instrument"
122 >
123 {instruments.map((ins) => (
124 <option key={ins.id} value={ins.id}>{ins.name}</option>
125 ))}
126 </select>
127 <button
128 className={`mini ${t.mute ? 'toggled' : ''}`}
129 title="Mute"
130 onClick={(e) => { e.stopPropagation(); store.updateTrack(t.id, { mute: !t.mute }, false); store.endEdit(); }}
131 >M</button>
132 <button
133 className={`mini ${t.solo ? 'toggled' : ''}`}
134 title="Solo"
135 onClick={(e) => { e.stopPropagation(); store.updateTrack(t.id, { solo: !t.solo }, false); store.endEdit(); }}
136 >S</button>
137 <button
138 className={`mini rec ${armed ? 'toggled' : ''}`}
139 title="Arm for recording"
140 onClick={(e) => { e.stopPropagation(); store.setArmedTrack(armed ? null : t.id); }}
141 >R</button>
142 <button className="mini" title="Move track up" onClick={(e) => { e.stopPropagation(); store.moveTrack(t.id, -1); }}>↑</button>
143 <button className="mini" title="Move track down" onClick={(e) => { e.stopPropagation(); store.moveTrack(t.id, 1); }}>↓</button>
144 <button className="mini" title="Duplicate track" onClick={(e) => { e.stopPropagation(); store.duplicateTrack(t.id); }}>⧉</button>
145 </div>
146 {active && (getInstrument(t.instrumentId).knobs ?? []).length > 0 && (
147 <div className="trackrow-knobs">
148 {(getInstrument(t.instrumentId).knobs ?? []).map((k) => (
149 <KnobSlider
150 key={k.key}
151 knob={k}
152 value={t.settings?.[k.key]}
153 onChange={(v) => store.setTrackSetting(t.id, k.key, v)}
154 />
155 ))}
156 </div>
157 )}
158 <div className="trackrow-vol">
159 <input
160 type="range"
161 min={0}
162 max={1.5}
163 step={0.01}
164 value={t.volume}
165 title={`Volume ${(t.volume * 100).toFixed(0)}%`}
166 onChange={(e) => store.updateTrack(t.id, { volume: Number(e.target.value) }, false)}
167 onClick={(e) => e.stopPropagation()}
168 />
169 <span className="notecount">{t.notes.length} notes</span>
170 </div>
171 </div>
172 );
173 })}
174 <button className="addtrack" onClick={() => store.addTrack()}>+ Add track</button>
175 <ImportedInstruments />
176 </div>
177 );
178}