diff --git a/docs/pipedal-components-reference.txt b/docs/pipedal-components-reference.txt new file mode 100644 index 0000000..a2b6fa9 --- /dev/null +++ b/docs/pipedal-components-reference.txt @@ -0,0 +1,593 @@ +=== FXScreen (block library) === +function FXScreen({ state, connected }) { + const [fxList, setFxList] = useState(null); + const [loading, setLoading] = useState(true); + const [showAdd, setShowAdd] = useState(false); + const [paramSchemas, setParamSchemas] = useState({}); + + // Load block param schemas + const loadParams = useCallback(async (fxType) => { + if (paramSchemas[fxType]) return paramSchemas[fxType]; + try { + const data = await apiGet(`/api/block-params/${fxType}`); + setParamSchemas(prev => ({ ...prev, [fxType]: data.params || [] })); + return data.params || []; + } catch (e) { return []; } + }, [paramSchemas]); + + useEffect(() => { + if (!connected || !state?.current_preset) { + // Offline: show a simple empty state + setFxList([]); + setLoading(false); + return; + } + + // Load active preset's chain from presets API + apiGet('/api/presets').then(async data => { + const banks = data.banks || []; + const cp = state.current_preset; + const preset = banks[cp.bank]?.presets?.[cp.program]; + if (preset?.chain) { + const blocks = await Promise.all(preset.chain.map(async (b, i) => { + const params = await loadParams(b.fx_type); + return { + id: i + 1, + title: FX_TYPE_LABELS[b.fx_type] || b.fx_type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), + type: b.fx_type, + bypassed: b.bypass || !b.enabled, + params: params, + }; + })); + setFxList(blocks); + } + setLoading(false); + }).catch(() => { + setFxList([]); + setLoading(false); + }); + }, [connected, state, loadParams]); + + const toggle = (id) => setFxList(prev => prev ? prev.map(f => f.id === id ? { ...f, bypassed: !f.bypassed } : f) : prev); + + const addBlock = async (fxType) => { + setShowAdd(false); + const params = await loadParams(fxType); + setFxList(prev => [...(prev || []), { + id: Date.now(), + title: FX_TYPE_LABELS[fxType] || fxType, + type: fxType, + bypassed: false, + params: params, + }]); + }; + + if (loading) return
; + + return ( +
+
+
FX Chain {(fxList?.length || 0) > 0 ? `(${fxList.length})` : ''}
+ +
+
+ {/* Signal path */} + {fxList && fxList.length > 0 && ( +
+ {["IN", ...fxList.map(f => f.type.toUpperCase()), "OUT"].map((s, i, arr) => ( +
+
{s}
+ {i < arr.length - 1 &&
} +
+ ))} +
+ )} + {(!fxList || fxList.length === 0) ? ( +
+ No FX blocks in the chain.
+ +
+ ) : ( + fxList.map(f => ( + toggle(f.id)} /> + )) + )} +
+ + {/* Add Block slide panel */} + {showAdd && ( + setShowAdd(false)}> +
+
Add FX Block
+
+
+ {Object.entries(FX_TYPE_LABELS).map(([key, label]) => ( +
addBlock(key)} style={{ borderRadius: 6 }}> + {key.split('_')[0]} + {label} + + +
+ ))} +
+
+ )} +
+ ); +} + +// 3. MODELS SCREEN ───────────────────────────────────────────── + + +=== ModelsScreen === +function ModelsScreen({ state, connected, refresh }) { + const [models, setModels] = useState([]); + const [current, setCurrent] = useState(null); + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + const [searchQ, setSearchQ] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [showSearch, setShowSearch] = useState(false); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + const loadModels = useCallback(async () => { + if (!connected) { setLoading(false); return; } + try { + const data = await apiGet('/api/models'); + setModels(data.models || []); + setCurrent(data.current); + } catch (e) { /* ignore */ } + setLoading(false); + }, [connected]); + + useEffect(() => { loadModels(); }, [loadModels]); + + const handleLoad = async (path) => { + try { await apiPost('/api/models/load', { path }); await loadModels(); refresh(); } catch (e) { alert('Failed to load model'); } + }; + + const handleUnload = async () => { + try { await apiPost('/api/models/unload'); setCurrent(null); await loadModels(); refresh(); } catch (e) { alert('Failed to unload'); } + }; + + const handleUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + try { + await apiUpload('/api/models/upload', file); + await loadModels(); + } catch (err) { alert('Upload failed'); } + setUploading(false); + }; + + const handleSearch = useCallback(async () => { + if (!searchQ.trim()) return; + setSearching(true); + try { + const data = await apiGet(`/api/models/tonedownload/search?q=${encodeURIComponent(searchQ)}`); + setSearchResults(data.results || []); + } catch (e) { alert('Search failed'); } + setSearching(false); + }, [searchQ]); + + const handleInstall = async (result) => { + try { + await apiPost('/api/models/tonedownload/install', { + download_url: result.download_url, + name: result.name || result.filename, + id: result.id, + size: result.size, + pack_name: result.pack_name, + display_name: result.name, + }); + await loadModels(); + refresh(); + } catch (e) { alert('Install failed'); } + }; + + if (loading) return
; + + return ( +
+
+
NAM Models ({models.length})
+
+ + + +
+
+ + {showSearch && ( +
+
+ 🔍 + setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} /> + +
+ {searchResults.length > 0 && ( +
+ {searchResults.map(r => ( +
handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}> +
+
{r.name}
+
{r.author} · {r.size_display} · {r.architecture}
+
+ +
+ ))} +
+ )} +
+ )} + +
+ {current && ( +
+
Loaded Model
+
+
{current}
+ +
+
+ )} + + {models.length === 0 ? ( +
+ No NAM models found.
Upload a .nam file or search Tone3000. +
+ ) : ( + models.map(m => ( +
+
+
{m.name}
+
{m.architecture} · {m.size_mb} MB
+
+ {m.loaded ? ( + Loaded + ) : ( + + )} +
+ )) + )} +
+
+ ); +} + +// 4. IRS SCREEN ──────────────────────────────────────────────── + + +=== IRsScreen === +function IRsScreen({ state, connected, refresh }) { + const [irs, setIrs] = useState([]); + const [current, setCurrent] = useState(null); + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + const [searchQ, setSearchQ] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [showSearch, setShowSearch] = useState(false); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + const loadIrs = useCallback(async () => { + if (!connected) { setLoading(false); return; } + try { + const data = await apiGet('/api/irs'); + setIrs(data.irs || []); + setCurrent(data.current); + } catch (e) { /* ignore */ } + setLoading(false); + }, [connected]); + + useEffect(() => { loadIrs(); }, [loadIrs]); + + const handleLoad = async (path) => { + try { await apiPost('/api/irs/load', { path }); await loadIrs(); refresh(); } catch (e) { alert('Failed to load IR'); } + }; + + const handleUnload = async () => { + try { await apiPost('/api/irs/unload'); setCurrent(null); await loadIrs(); refresh(); } catch (e) { alert('Failed to unload'); } + }; + + const handleUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + try { + await apiUpload('/api/irs/upload', file); + await loadIrs(); + } catch (err) { alert('Upload failed'); } + setUploading(false); + }; + + const handleSearch = useCallback(async () => { + if (!searchQ.trim()) return; + setSearching(true); + try { + const data = await apiGet(`/api/irs/tonedownload/search?q=${encodeURIComponent(searchQ)}`); + setSearchResults(data.results || []); + } catch (e) { alert('Search failed'); } + setSearching(false); + }, [searchQ]); + + const handleInstall = async (result) => { + try { + await apiPost('/api/irs/tonedownload/install', { + download_url: result.download_url, + name: result.name || result.filename, + id: result.id, + size: result.size, + display_name: result.name, + }); + await loadIrs(); + refresh(); + } catch (e) { alert('Install failed'); } + }; + + if (loading) return
; + + return ( +
+
+
IR Files ({irs.length})
+
+ + + +
+
+ + {showSearch && ( +
+
+ 🔍 + setSearchQ(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} /> + +
+ {searchResults.length > 0 && ( +
+ {searchResults.map(r => ( +
handleInstall(r)} style={{ borderBottom: `1px solid ${T.border}`, borderRadius: 0 }}> +
+
{r.name}
+
{r.author} · {r.size_display}
+
+ +
+ ))} +
+ )} +
+ )} + +
+ {current && ( +
+
Loaded IR
+
+
{current}
+ +
+
+ )} + + {irs.length === 0 ? ( +
+ No IR files found.
Upload a .wav file or search Tone3000. +
+ ) : ( + irs.map(ir => ( +
+
+
{ir.name}
+
{ir.sample_rate} Hz · {ir.length_ms} ms · {ir.num_taps} taps
+
+ {ir.loaded ? ( + Loaded + ) : ( + + )} +
+ )) + )} +
+
+ ); +} + +// 5. PRESETS SCREEN ──────────────────────────────────────────── + + +=== PresetsScreen === +function PresetsScreen({ state, connected, refresh }) { + const [banks, setBanks] = useState([]); + const [selectedBank, setSelectedBank] = useState(0); + const [loading, setLoading] = useState(true); + const [saveName, setSaveName] = useState(''); + const [showSave, setShowSave] = useState(false); + const [deleting, setDeleting] = useState(null); + + const loadPresets = useCallback(async () => { + if (!connected) { + setBanks([{ name: "Default", number: 0, presets: [ + { name: "Edge of Breakup", bank: 0, program: 0 }, + { name: "Modern High Gain", bank: 0, program: 1 }, + null, null, + ]}]); + setLoading(false); + return; + } + try { + const data = await apiGet('/api/presets'); + setBanks(data.banks || []); + } catch (e) { /* ignore */ } + setLoading(false); + }, [connected]); + + useEffect(() => { loadPresets(); }, [loadPresets]); + + const handleActivate = async (bank, program) => { + try { + await apiPost(`/api/presets/${bank}/${program}/activate`); + await loadPresets(); + refresh(); + } catch (e) { alert('Failed to load preset'); } + }; + + const handleSave = async () => { + if (!saveName.trim()) return; + const currentBank = state?.current_preset?.bank ?? 0; + const currentProgram = state?.current_preset?.program ?? 0; + try { + await apiPut(`/api/presets/${currentBank}/${currentProgram}`, { name: saveName }); + setShowSave(false); + await loadPresets(); + } catch (e) { alert('Failed to save'); } + }; + + const handleNewPreset = async () => { + // Find the first empty slot, or create at next program number + const bank = selectedBank; + const bankData = banks[bank]; + const presets = bankData?.presets || []; + let nextProg = presets.findIndex(p => p == null); + if (nextProg === -1) nextProg = presets.length; + const name = `New Preset ${nextProg + 1}`; + try { + await apiPut(`/api/presets/${bank}/${nextProg}`, { name }); + await loadPresets(); + await apiPost(`/api/presets/${bank}/${nextProg}/activate`); + refresh(); + } catch (e) { alert('Failed to create preset'); } + }; + + const handleDelete = async (bank, program) => { + try { + await apiDelete(`/api/presets/${bank}/${program}`); + setDeleting(null); + await loadPresets(); + } catch (e) { alert('Failed to delete'); } + }; + + if (loading) return
; + + const currentPreset = state?.current_preset; + const bankData = banks[selectedBank]; + const presets = bankData?.presets || []; + + return ( +
+
+
Presets
+
+ + +
+
+ +
+ {/* Bank selector */} + {banks.length > 1 && ( +
+ {banks.map((b, i) => ( + + ))} +
+ )} + + {/* Current preset indicator */} + {currentPreset && ( +
+
+
Current
+
+ {currentPreset.name} + + Bank {currentPreset.bank} · Prog {currentPreset.program} + +
+
+
+ )} + + {/* Preset list */} + {presets.length === 0 ? ( +
No presets in this bank.
+ ) : ( + presets.map((p, i) => { + if (!p) return null; + const isActive = currentPreset?.bank === p.bank && currentPreset?.program === p.program; + const isDeleting = deleting === `${p.bank}-${p.program}`; + return ( +
!isDeleting && handleActivate(p.bank, p.program)}> +
{p.program + 1}
+
+
{p.name}
+
Bank {p.bank} · Program {p.program}
+
+ {isActive && } + {isDeleting ? ( +
+ + +
+ ) : ( + + )} +
+ ); + }) + )} +
+ + {/* Save dialog */} + {showSave && ( + setShowSave(false)}> +
+
Save Preset
+
+
+
+ setSaveName(e.target.value)} autoFocus + onKeyDown={e => e.key === 'Enter' && handleSave()} /> +
+
+ + +
+
+
+ )} +
+ ); +} + +// 6. SETTINGS SCREEN ─────────────────────────────────────────── diff --git a/docs/snapshot-reference.jsx b/docs/snapshot-reference.jsx new file mode 100644 index 0000000..a1bd3e7 --- /dev/null +++ b/docs/snapshot-reference.jsx @@ -0,0 +1,156 @@ +function SnapshotPanel({ onClose, state, connected }) { + const [snapshots, setSnapshots] = useState({}); + const [loading, setLoading] = useState(true); + const [editingSlot, setEditingSlot] = useState(null); + const [editName, setEditName] = useState(''); + const [saveFlash, setSaveFlash] = useState(null); + const [discardEdits, setDiscardEdits] = useState(false); + const holdTimer = useRef(null); + const currentSlot = state?.current_snapshot ?? 0; + + const loadSnapshots = useCallback(async () => { + if (!connected) { setLoading(false); return; } + try { + const data = await apiGet('/api/snapshots'); + setSnapshots(data.snapshots || {}); + } catch (e) { /* ignore */ } + setLoading(false); + }, [connected]); + + useEffect(() => { loadSnapshots(); }, [loadSnapshots]); + + // Handle press & hold vs tap + const handlePointerDown = (slot) => { + holdTimer.current = setTimeout(() => { + // Long press → save + holdTimer.current = null; + handleSave(slot); + }, 600); + }; + + const handlePointerUp = (slot) => { + if (holdTimer.current) { + clearTimeout(holdTimer.current); + holdTimer.current = null; + // Short tap → recall (or save if empty) + if (snapshots[String(slot)]) { + handleRecall(slot); + } else { + handleSave(slot); + } + } + }; + + const handlePointerLeave = () => { + if (holdTimer.current) { + clearTimeout(holdTimer.current); + holdTimer.current = null; + } + }; + + const handleSave = async (slot) => { + try { + await apiPost(`/api/snapshots/${slot}/save`); + setSaveFlash(slot); + setTimeout(() => setSaveFlash(null), 800); + await loadSnapshots(); + } catch (e) { /* ignore */ } + }; + + const handleRecall = async (slot) => { + try { + await apiPost(`/api/snapshots/${slot}/recall`); + await loadSnapshots(); + } catch (e) { /* ignore */ } + }; + + const handleRename = async (slot) => { + if (!editName.trim()) return; + try { + await apiPut(`/api/snapshots/${slot}`, { name: editName.trim() }); + setEditingSlot(null); + await loadSnapshots(); + } catch (e) { /* ignore */ } + }; + + const handleDelete = async (slot) => { + try { + await apiDelete(`/api/snapshots/${slot}`); + await loadSnapshots(); + } catch (e) { /* ignore */ } + }; + + return ( + +
+
Snapshots
+
+
+
+
+ Tap to recall · Hold to save + {discardEdits && Edits will be discarded on recall} +
+
+ {[1,2,3,4,5,6,7,8].map(slot => { + const snap = snapshots[String(slot)]; + const isActive = currentSlot === slot; + const isFilled = !!snap; + const isEditing = editingSlot === slot; + const isFlashing = saveFlash === slot; + return ( +
handlePointerDown(slot)} + onTouchStart={() => handlePointerDown(slot)} + onMouseUp={() => handlePointerUp(slot)} + onTouchEnd={() => handlePointerUp(slot)} + onMouseLeave={handlePointerLeave} + onTouchCancel={handlePointerLeave} + style={isFlashing ? { background: 'rgba(58,184,122,.15)' } : {}}> +
{slot}
+ {isEditing ? ( + setEditName(e.target.value)} + onBlur={() => handleRename(slot)} + onKeyDown={e => { if (e.key === 'Enter') handleRename(slot); if (e.key === 'Escape') setEditingSlot(null); }} + autoFocus onClick={e => e.stopPropagation()} /> + ) : snap ? ( +
{ + e.stopPropagation(); + setEditingSlot(slot); + setEditName(snap.name); + }}>{snap.name}
+ ) : ( +
Empty
+ )} + {isFlashing &&
} + {isActive &&
} +
+ ); + })} +
+ + {/* Delete button for active snapshot */} + {currentSlot > 0 && snapshots[String(currentSlot)] && ( +
+ +
+ )} +
+ + ); +} diff --git a/src/App.jsx b/src/App.jsx index 9b04a8c..8465be6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -638,8 +638,12 @@ export default function App(){ CPU {state.cpu_percent!=null?`${state.cpu_percent}%`:"—"}
- - + +
{["stomp","preset"].map(m=>())}
- - + borderColor:state.tuner_enabled?T.green:T.border}} + onClick={handleTunerToggle} title={state.tuner_enabled?"Exit Tuner":"Tuner"}> + tuner + +
diff --git a/src/BlockChain.jsx b/src/BlockChain.jsx index 0118d24..6071b2e 100644 --- a/src/BlockChain.jsx +++ b/src/BlockChain.jsx @@ -40,6 +40,70 @@ const TYPE_COLORS = { split: "#B080C0", merge: "#80B0C0", }; +// ── Type-to-SVG-icon mapping (from pipedal icon set) ── +const TYPE_ICON_MAP = { + od: "fx_distortion", overdrive: "fx_distortion", drive: "fx_distortion", + dist: "fx_distortion", distortion: "fx_distortion", gain: "fx_distortion", + fuzz: "fx_distortion", + delay: "fx_delay", echo: "fx_delay", + reverb: "fx_reverb", + chorus: "fx_chorus", flange: "fx_flanger", flanger: "fx_flanger", + phaser: "fx_phaser", tremolo: "fx_modulator", vibrato: "fx_modulator", + rotary: "fx_modulator", modulator: "fx_modulator", + pitch: "fx_pitch", harmonizer: "fx_pitch", shifter: "fx_pitch", + comp: "fx_compressor", compressor: "fx_compressor", + limiter: "fx_limiter", + gate: "fx_gate", noise_gate: "fx_gate", + filter: "fx_filter", wah: "fx_filter", + eq: "fx_eq", graphic_eq: "fx_eq", + parametric_eq: "fx_parametric_eq", multiband_eq: "fx_multiband_eq", + ir: "fx_spatial", cab: "fx_spatial", cabinet: "fx_spatial", + nam: "fx_simulator", capture: "fx_simulator", amp: "fx_amplifier", + amplifier: "fx_amplifier", + volume: "fx_constant", level: "fx_constant", boost: "fx_constant", + split: "fx_split_a", merge: "fx_mixer", + tuner: "fx_analyzer", + mixer: "fx_mixer", utility: "fx_utility", + plugin: "fx_plugin", + instrument: "fx_instrument", + reamp: "fx_converter", + looper: "fx_oscillator", +}; + +const IMG_PATH = (import.meta.env.BASE_URL || '/ui/') + 'img/'; + +// ── SVG Block Icon component ───────────────────────── +function BlockIcon({ type, size = 14, style = {} }) { + const key = (type || '').toLowerCase().trim(); + let icon = 'fx_plugin'; + let found = false; + for (const [k, v] of Object.entries(TYPE_ICON_MAP)) { + if (key === k || key.startsWith(k) || key.includes(k)) { + icon = v; + found = true; + break; + } + } + if (!found && key) { + // Try direct match by replacing spaces/special chars + const slug = key.replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); + if (TYPE_ICON_MAP[slug]) icon = TYPE_ICON_MAP[slug]; + } + return ( + {type { e.target.style.display = 'none'; }} + /> + ); +} + function getBlockColor(type) { const key = (type || "").toLowerCase().trim(); for (const [k, v] of Object.entries(TYPE_COLORS)) { @@ -48,30 +112,6 @@ function getBlockColor(type) { return T.amber; } -function getBlockIcon(type) { - const key = (type || "").toLowerCase(); - if (key === "split") return "⇄"; - if (key === "merge") return "⨁"; - if (key.includes("dist") || key.includes("gain")) return "💥"; - if (key.includes("fuzz")) return "⚡"; - if (key.includes("delay") || key.includes("echo")) return "⏳"; - if (key.includes("reverb")) return "🌊"; - if (key.includes("pitch") || key.includes("harm") || key.includes("shift")) return "🎵"; - if (key.includes("comp") || key.includes("limit")) return "📊"; - if (key.includes("gate") || key.includes("noise")) return "🔇"; - if (key.includes("filter") || key.includes("wah")) return "📈"; - if (key.includes("eq")) return "⚖️"; - if (odMatch(key)) return "🔥"; - if (modMatch(key)) return "🌀"; - if (key.includes("ir") || key.includes("cab")) return "🔊"; - if (key.includes("nam") || key.includes("capture")) return "🎛"; - if (key.includes("volume") || key.includes("level") || key.includes("boost")) return "🔈"; - if (key.includes("tuner")) return "🎹"; - return "▣"; - function odMatch(k) { return k.includes("od")||k.includes("overdrive")||k.includes("drive")||k.includes("boost")&&!k.includes("volume"); } - function modMatch(k) { return k.includes("mod")||k.includes("chorus")||k.includes("flange")||k.includes("phaser")||k.includes("trem")||k.includes("vibr")||k.includes("rotary"); } -} - // ── Chain Arrow SVG ────────────────────────────── function ChainArrow({ color = T.border, narrow = false }) { const w = narrow ? 14 : 18; @@ -166,7 +206,7 @@ function SplitBlockTile({ block, selected, onSelect, onToggle }) { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6, }}> - + SPLIT {st.label}
@@ -234,7 +274,7 @@ function MergeBlockTile({ block, selected, onSelect, onToggle }) { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", lineHeight: 1.3, display: "flex", alignItems: "center", gap: 6, }}> - + MERGE
@@ -332,7 +372,7 @@ function BlockTile({ block, selected, onSelect, onToggle, pathLabel }) { {block.name || block.type || "FX"}
- {getBlockIcon(block.type)} +