From 3e2369651a9df69d04c18007d8074f67f1853cc5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 27 Jun 2024 19:04:45 +0200 Subject: [PATCH] Channel config, LocalStorage saving merged into one row --- src/assets/pickercircle.svg | 4 + src/assets/pickerline.svg | 4 + src/core/AudioWorker.ts | 28 +- src/core/Renderer.ts | 52 +++- src/core/TimelineRenderer.ts | 7 +- src/ui/ChannelsSection.tsx | 143 ++++++++++ src/ui/ColorPicker.tsx | 333 +++++++++++++++++++++++ src/ui/ConfigSection.tsx | 98 +++---- src/ui/ControlPanel.tsx | 24 +- src/ui/Helpers.ts | 26 +- src/ui/styles/ChannelsSection.module.css | 21 ++ src/ui/styles/Collapsible.module.css | 1 + src/ui/styles/ColorPicker.module.css | 81 ++++++ 13 files changed, 762 insertions(+), 60 deletions(-) create mode 100644 src/assets/pickercircle.svg create mode 100644 src/assets/pickerline.svg create mode 100644 src/ui/ChannelsSection.tsx create mode 100644 src/ui/ColorPicker.tsx create mode 100644 src/ui/styles/ChannelsSection.module.css create mode 100644 src/ui/styles/ColorPicker.module.css diff --git a/src/assets/pickercircle.svg b/src/assets/pickercircle.svg new file mode 100644 index 0000000..f04d315 --- /dev/null +++ b/src/assets/pickercircle.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/pickerline.svg b/src/assets/pickerline.svg new file mode 100644 index 0000000..35600ae --- /dev/null +++ b/src/assets/pickerline.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/core/AudioWorker.ts b/src/core/AudioWorker.ts index acbd387..40b2044 100644 --- a/src/core/AudioWorker.ts +++ b/src/core/AudioWorker.ts @@ -81,6 +81,7 @@ callables.set("getSeqSymbols", (data) => { let curSeqSymbol: string; let renderer: Audio.SequenceRenderer; +let activeTracks: number = 0xffff; callables.set("loadSeq", (data) => { curSeqSymbol = data.name; @@ -93,7 +94,8 @@ callables.set("loadSeq", (data) => { sink(buffer) { postMessage({ type: "pcm", data: buffer }); }, - bufferLength: 1024 * 16 + bufferLength: 1024 * 16, + activeTracks: activeTracks }); }); @@ -115,6 +117,30 @@ callables.set("tickSeconds", (data) => { return states; }); +callables.set("setTrackActive", (data) => { + if (data.active) { + activeTracks |= 1 << data.track; + } else { + activeTracks &= ~(1 << data.track); + } + + if (renderer && renderer.tracks[data.track]) { + // This should probably be part of the renderer itself + renderer.activeTracks = activeTracks; + renderer.tracks[data.track].active = data.active; + } +}); + +callables.set("findAllocatedTracks", (data) => { + let tracks = 0xffff; + const cmd = renderer.commands[0]; + if (cmd instanceof Audio.Commands.AllocateTracks) { + tracks = cmd.tracks; + } + + return tracks; +}); + let exportRenderer: Audio.SequenceRenderer; let exportBuffer: Float32Array[] | null = null; diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 5ea8642..f61d72c 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -23,6 +23,10 @@ let topTime = 1.5; let bottomTime = -1; let bottomSameSpeed = true; +let topChannels = 0xffff; +let pianoChannels = 0xffff; +let bottomChannels = 0xffff; + export function init() { PianoRenderer.init(); @@ -71,13 +75,23 @@ export function resize() { function render() { const time = AudioPlayer.getTime(); - topTimeline.draw(colors, time, noteRange, [0, topTime]); - bottomTimeline.draw(colors, time, noteRange, [bottomTime, 0]); + topTimeline.draw(colors, time, noteRange, [0, topTime], topChannels); + bottomTimeline.draw( + colors, + time, + noteRange, + [bottomTime, 0], + bottomChannels + ); const state = StateManager.getState(time); PianoRenderer.clearNotes(); if (state) { for (let i = 0; i < state.channels.length; i++) { + if ((pianoChannels & (1 << i)) === 0) { + continue; + } + const channel = state.channels[i]; for (const note of channel.playing) { if (note.state === Audio.EnvelopeState.Release) { @@ -114,3 +128,37 @@ export function setPianoRange(value: [number, number]) { noteRange = value; PianoRenderer.drawKeys(noteRange[0], noteRange[1]); } + +export function showChannel( + place: "top" | "piano" | "bottom", + channel: number +) { + switch (place) { + case "top": + topChannels |= 1 << channel; + break; + case "piano": + pianoChannels |= 1 << channel; + break; + case "bottom": + bottomChannels |= 1 << channel; + break; + } +} + +export function hideChannel( + place: "top" | "piano" | "bottom", + channel: number +) { + switch (place) { + case "top": + topChannels &= ~(1 << channel); + break; + case "piano": + pianoChannels &= ~(1 << channel); + break; + case "bottom": + bottomChannels &= ~(1 << channel); + break; + } +} diff --git a/src/core/TimelineRenderer.ts b/src/core/TimelineRenderer.ts index 40bdde3..b0ba574 100644 --- a/src/core/TimelineRenderer.ts +++ b/src/core/TimelineRenderer.ts @@ -18,7 +18,8 @@ export class TimelineRenderer { colors: string[], time: number, noteRange: [Audio.Note, Audio.Note], - timeRange: [number, number] + timeRange: [number, number], + shownChannels: number ) { if (this.canvas.height === 0) { return; @@ -49,6 +50,10 @@ export class TimelineRenderer { } for (let i = 0; i < s.channels.length; i++) { + if ((shownChannels & (1 << i)) === 0) { + continue; + } + const channel = s.channels[i]; this.ctx.fillStyle = colors[i]; diff --git a/src/ui/ChannelsSection.tsx b/src/ui/ChannelsSection.tsx new file mode 100644 index 0000000..eb64397 --- /dev/null +++ b/src/ui/ChannelsSection.tsx @@ -0,0 +1,143 @@ +import { useEffect } from "react"; +import { repeat, useStorage } from "./Helpers"; +import * as classes from "./styles/ChannelsSection.module.css"; +import * as AudioWorkerComms from "../core/AudioWorkerComms"; +import * as Renderer from "../core/Renderer"; +import { Color, ColorPicker, HSVtoRGB, roundColor } from "./ColorPicker"; + +// TODO: Make some nice icons for this instead of text +export function ChannelsSection({ allocatedTracks }) { + return ( +
+
Some of these settings may take a few seconds to apply.
+
+
Channel
+
Color
+
Sound
+
Top
+
Piano
+
Bottom
+
+ {repeat(16, (i) => ( + + ))} +
+
+ ); +} + +function ChannelRow({ id, disabled }) { + const [color, setColor] = useStorage( + `channel_${id}_color`, + roundColor(HSVtoRGB((id * 360) / 16, 1, 1)) + ); + const [active, setActive] = useStorage( + `channel_${id}_active`, + true + ); + const [showTop, setShowTop] = useStorage( + `channel_${id}_showTop`, + true + ); + const [showPiano, setShowPiano] = useStorage( + `channel_${id}_showPiano`, + true + ); + const [showBottom, setShowBottom] = useStorage( + `channel_${id}_showBottom`, + true + ); + + useEffect(() => { + AudioWorkerComms.call("setTrackActive", { + track: id, + active: active + }); + }, [active]); + + useEffect(() => { + if (showTop) { + Renderer.showChannel("top", id); + } else { + Renderer.hideChannel("top", id); + } + }, [showTop]); + + useEffect(() => { + if (showPiano) { + Renderer.showChannel("piano", id); + } else { + Renderer.hideChannel("piano", id); + } + }, [showPiano]); + + useEffect(() => { + if (showBottom) { + Renderer.showChannel("bottom", id); + } else { + Renderer.hideChannel("bottom", id); + } + }, [showBottom]); + + return ( + <> +
+ {disabled ? ( + <> + {id + 1} + 🔇 + + ) : ( + <>{id + 1} + )} +
+ setColor(c)} /> + setActive(e.target.checked)} + > + { + setShowTop(e.target.checked); + }} + > + { + setShowPiano(e.target.checked); + }} + > + { + setShowBottom(e.target.checked); + }} + > + + + ); +} diff --git a/src/ui/ColorPicker.tsx b/src/ui/ColorPicker.tsx new file mode 100644 index 0000000..dfd581c --- /dev/null +++ b/src/ui/ColorPicker.tsx @@ -0,0 +1,333 @@ +import { useEffect, useRef, useState } from "react"; +import * as classes from "./styles/ColorPicker.module.css"; +import * as panelClasses from "./styles/Panel.module.css"; +import { NumberSpinner } from "./NumberSpinner"; + +export type Color = { r: number; g: number; b: number }; +export type HSVColor = { h: number; s: number; v: number }; + +export function ColorPicker({ + color, + onChange +}: { + color: Color; + onChange: (color: Color) => void; +}) { + const [show, setShow] = useState(false); + const [dialogX, setDialogX] = useState(0); + const [dialogY, setDialogY] = useState(0); + const dialogRef = useRef(null); + const buttonRef = useRef(null); + + // Just for display purposes + const [hsvColor, setHsvColor] = useState(() => { + const hsv = RGBtoHSV(color.r, color.g, color.b); + return roundColor(hsv); + }); + const hueOnly = HSVtoRGB(hsvColor.h, 1, 1); + + function setupOutsideClickListeners() { + function listener(e: MouseEvent) { + if (!show || !dialogRef.current || !buttonRef.current) { + return; + } + + // Check if click is outside the panel and button + const buttonRect = buttonRef.current.getBoundingClientRect(); + const dialogRect = dialogRef.current.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + if ( + x < buttonRect.left || + x > buttonRect.right || + y < buttonRect.top || + y > buttonRect.bottom + ) { + if ( + x < dialogRect.left || + x > dialogRect.right || + y < dialogRect.top || + y > dialogRect.bottom + ) { + setShow(false); + } + } + } + + document.addEventListener("click", listener); + return () => document.removeEventListener("click", listener); + } + + useEffect(() => { + if (show) { + if (dialogRef.current) { + dialogRef.current.show(); + } + + return setupOutsideClickListeners(); + } else { + if (dialogRef.current) { + dialogRef.current.close(); + } + } + }, [show]); + + function setRGB(r: number, g: number, b: number) { + r = Math.min(255, Math.max(0, r)); + g = Math.min(255, Math.max(0, g)); + b = Math.min(255, Math.max(0, b)); + + const hsv = RGBtoHSV(r, g, b); + setHsvColor(roundColor(hsv)); + onChange(roundColor({ r, g, b })); + } + + function setHSV(h: number, s: number, v: number) { + h = Math.min(360, Math.max(0, h)); + s = Math.min(1, Math.max(0, s)); + v = Math.min(1, Math.max(0, v)); + + setHsvColor(roundColor({ h, s, v })); + const rgb = HSVtoRGB(h, s, v); + onChange(roundColor(rgb)); + } + + return ( +
+ + {show ? ( + +
{ + if (e.buttons === 0) { + return; + } + + const rect = + e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const v = x / rect.width; + const s = 1 - y / rect.height; + + setHSV(hsvColor.h, s, v); + }} + > + +
+ +
{ + if (e.buttons === 0) { + return; + } + + const rect = + e.currentTarget.getBoundingClientRect(); + const y = e.clientY - rect.top; + const h = (y / rect.height) * 360; + + setHSV(h, hsvColor.s, hsvColor.v); + }} + > + +
+ +
+
+ R + + setRGB(v, color.g, color.b) + } + /> +
+
+ G + + setRGB(color.r, v, color.b) + } + /> +
+
+ B + + setRGB(color.r, color.g, v) + } + /> +
+ +
+ H + + setHSV(v, hsvColor.s, hsvColor.v) + } + /> +
+
+ S + + setHSV(hsvColor.h, v, hsvColor.v) + } + /> +
+
+ V + + setHSV(hsvColor.h, hsvColor.s, v) + } + /> +
+
+
+ ) : ( + <> + )} +
+ ); +} + +// https://github.com/DanielPXL/PXLed/blob/master/PXLed/Color24.cs#L121 +export function HSVtoRGB(h: number, s: number, v: number): Color { + const hi = Math.floor(h / 60) % 6; + const f = h / 60 - Math.floor(h / 60); + + v *= 255; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + + switch (hi) { + case 0: + return { r: v, g: t, b: p }; + case 1: + return { r: q, g: v, b: p }; + case 2: + return { r: p, g: v, b: t }; + case 3: + return { r: p, g: q, b: v }; + case 4: + return { r: t, g: p, b: v }; + case 5: + return { r: v, g: p, b: q }; + } +} + +export function RGBtoHSV(r: number, g: number, b: number): HSVColor { + r /= 255; + g /= 255; + b /= 255; + + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + + let h = 0; + if (d === 0) { + h = 0; + } else if (max === r) { + h = ((g - b) / d) % 6; + } else if (max === g) { + h = (b - r) / d + 2; + } else if (max === b) { + h = (r - g) / d + 4; + } + + h = h * 60; + if (h < 0) { + h += 360; + } + + const s = max === 0 ? 0 : d / max; + const v = max; + + return { h, s, v }; +} + +export function roundColor(color: Color): Color; +export function roundColor(color: HSVColor): HSVColor; +export function roundColor(color: Color | HSVColor): Color | HSVColor { + if ("r" in color) { + return { + r: Math.round(color.r), + g: Math.round(color.g), + b: Math.round(color.b) + }; + } else { + return { + h: Math.round(color.h), + s: Math.round(color.s * 100) / 100, + v: Math.round(color.v * 100) / 100 + }; + } +} diff --git a/src/ui/ConfigSection.tsx b/src/ui/ConfigSection.tsx index 0737691..b5b3175 100644 --- a/src/ui/ConfigSection.tsx +++ b/src/ui/ConfigSection.tsx @@ -6,58 +6,60 @@ import { } from "./ConfigGrid"; import * as Renderer from "../core/Renderer"; import { Collapsible } from "./Collapsible"; +import { ChannelsSection } from "./ChannelsSection"; -export function ConfigSection() { +export function ConfigSection({ allocatedTracks }) { return ( <>

Configuration

-
- - - - Renderer.alignNotesToPiano(checked) - } - /> - - Renderer.setPianoPosition(value) - } - /> - - Renderer.setPianoHeight(value) - } - /> - - Renderer.setPianoRange(value) - } - /> - - -
+ + + + + + + Renderer.alignNotesToPiano(checked) + } + /> + + Renderer.setPianoPosition(value) + } + /> + + Renderer.setPianoHeight(value) + } + /> + + Renderer.setPianoRange(value) + } + /> + + ); } diff --git a/src/ui/ControlPanel.tsx b/src/ui/ControlPanel.tsx index ce7e243..da401f2 100644 --- a/src/ui/ControlPanel.tsx +++ b/src/ui/ControlPanel.tsx @@ -11,6 +11,7 @@ export function ControlPanel({ show, load, start, onClose }) { const [selectedIndex, setSelectedIndex] = useState(0); const [isLoading, setIsLoading] = useState(false); const [isPlaying, setIsPlaying] = useState(false); + const [allocatedTracks, setAllocatedTracks] = useState(0xffff); return (
{ - setIsLoading(false); + AudioWorkerComms.call( + "findAllocatedTracks" + ).then((t) => { + setAllocatedTracks(t); + setIsLoading(false); + }); }); }); }} @@ -47,7 +53,12 @@ export function ControlPanel({ show, load, start, onClose }) { setSelectedIndex(index); setIsLoading(true); load(seqs[index]).then(() => { - setIsLoading(false); + AudioWorkerComms.call("findAllocatedTracks").then( + (t) => { + setAllocatedTracks(t); + setIsLoading(false); + } + ); }); }} onPlay={() => { @@ -60,14 +71,19 @@ export function ControlPanel({ show, load, start, onClose }) { // Loading stops playback setIsLoading(true); load(seqs[selectedIndex]).then(() => { - setIsLoading(false); + AudioWorkerComms.call("findAllocatedTracks").then( + (t) => { + setAllocatedTracks(t); + setIsLoading(false); + } + ); }); document.title = "NitroPlay"; }} />
- +
); diff --git a/src/ui/Helpers.ts b/src/ui/Helpers.ts index 2cc3ce2..2ce3380 100644 --- a/src/ui/Helpers.ts +++ b/src/ui/Helpers.ts @@ -2,19 +2,37 @@ import { useState, useEffect } from "react"; export const storagePrefix = "nitro-play"; +function getStorage() { + const storage = localStorage.getItem(storagePrefix); + return storage ? JSON.parse(storage) : {}; +} + +function setStorage(storage: { [key: string]: string }) { + localStorage.setItem(storagePrefix, JSON.stringify(storage)); +} + export function useStorage( key: string, defaultValue: any ): [T, React.Dispatch>] { - const k = `${storagePrefix}_${key}`; const [value, setValue] = useState(() => { - const storedValue = localStorage.getItem(k); - return storedValue !== null ? JSON.parse(storedValue) : defaultValue; + const storage = getStorage(); + const storedValue = storage[key]; + const parsed = storedValue ? JSON.parse(storedValue) : defaultValue; + return parsed; }); useEffect(() => { - localStorage.setItem(k, JSON.stringify(value)); + const storage = getStorage(); + storage[key] = JSON.stringify(value); + setStorage(storage); }, [key, value]); return [value, setValue]; } + +export function repeat(n: number, fn: (i: number) => T) { + return Array(n) + .fill(0) + .map((_, i) => fn(i)); +} diff --git a/src/ui/styles/ChannelsSection.module.css b/src/ui/styles/ChannelsSection.module.css new file mode 100644 index 0000000..402b90c --- /dev/null +++ b/src/ui/styles/ChannelsSection.module.css @@ -0,0 +1,21 @@ +.container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.grid { + display: grid; + grid-template-columns: auto repeat(5, 1fr) auto; + grid-auto-flow: row; + gap: 2px 4px; + align-items: center; +} + +.rightAlign { + justify-self: end; +} + +.resetButton { + padding: 2px; +} diff --git a/src/ui/styles/Collapsible.module.css b/src/ui/styles/Collapsible.module.css index b574efd..965b966 100644 --- a/src/ui/styles/Collapsible.module.css +++ b/src/ui/styles/Collapsible.module.css @@ -26,6 +26,7 @@ .wrapper .collapsed { max-height: 100%; + padding-bottom: 8px; } .arrow { diff --git a/src/ui/styles/ColorPicker.module.css b/src/ui/styles/ColorPicker.module.css new file mode 100644 index 0000000..e41cd06 --- /dev/null +++ b/src/ui/styles/ColorPicker.module.css @@ -0,0 +1,81 @@ +.container { + position: relative; + height: 100%; + width: 100%; +} + +.preview { + width: 100%; + height: 100%; + border-radius: 4px; + border: 1px solid #505050; + cursor: pointer; +} + +.preview:hover { + border: 1px solid #fff; +} + +.panel { + /* For some reason, position: fixed is the only way to escape scoll overflow */ + position: fixed; + z-index: 1; + display: flex; + flex-direction: row; + gap: 10px; +} + +.satValuePicker { + width: 200px; + height: 200px; + background-blend-mode: multiply; +} + +.pickerCircle { + width: 20px; + height: 20px; + position: relative; + transform: translate(-50%, -50%); + user-select: none; +} + +.huePicker { + width: 20px; + height: 200px; + background: linear-gradient( + to bottom, + #f00 0%, + #ff0 16.66%, + #0f0 33.33%, + #0ff 50%, + #00f 66.66%, + #f0f 83.33%, + #f00 100% + ); +} + +.pickerLine { + width: 100%; + height: 20px; + position: relative; + transform: translate(0, -50%); + user-select: none; +} + +.manualPicker { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; + align-items: flex-start; +} + +.manualPickerEntry { + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; +} + +.manualPickerEntry input { + width: 55px; +}