1// Jev's hands on the desk: the answers playing now, as a mixer at the foot 2// of the pane under the editor (JevPane.jsx), idle until a song plays. A 3// score is a fader (and so is a choice among out/back/full, the way some 4// songs write a part's level), a noul a knob, a choice a rotary selector 5// with a notch per option, and the form an LCD naming the section. Each 6// control glides to its new setting as a section Jev decided begins, a 7// ghost marks the next section's setting once Jev has answered it, and a 8// fader whose part plays on its own orbit (`.orbit('drums')`, see 9// meter.mjs) has a live level meter beside it. 10import { useEffect, useState } from 'react'; 11import { latest } from './meter.mjs'; 12import { sandboxLevels } from './sandbox.mjs'; 13import { playing, questionsOf, useBooth, usePlayhead } from './JevBooth.jsx'; 14import { themeColor as ACCENT } from './site.mjs'; 15 16const GLIDE = 'cubic-bezier(.3,.7,.2,1) 900ms'; 17const ARC = 270; // a knob's sweep, in degrees 18// songs' part levels, when written as a choice rather than a score 19const LADDER = { out: 0, back: 0.5, full: 1 }; 20 21// The meter's newest reading, a few times a second while playing: the 22// sandbox's while a listener's song plays there (sandbox.mjs). 23function useLevels(started) { 24 const [levels, setLevels] = useState(null); 25 useEffect(() => { 26 if (!started) { 27 setLevels(null); 28 return; 29 } 30 const timer = setInterval(() => setLevels(sandboxLevels() ?? latest()), 100); 31 return () => clearInterval(timer); 32 }, [started]); 33 return levels; 34} 35 36// linear rms → 0..1 over -60..0 dBFS 37const meterShare = (l) => (l && l.rms > 0 ? Math.min(Math.max((20 * Math.log10(l.rms) + 60) / 60, 0), 1) : 0); 38 39// A value Jev did not choose: the song's default because Jev could not 40// answer, as opposed to one the form forced. 41const isDefault = (a) => a?.fallback && !a.forced && !a.absent; 42 43function Strip({ name, value, title, fallback, absent, children }) { 44 return ( 45 <div 46 className="flex flex-col items-center shrink-0 w-16 gap-1" 47 title={absent ? `${name} does not play in this section, so Jev is not asked` : title} 48 style={{ opacity: absent ? 0.3 : 1, transition: 'opacity 300ms' }} 49 > 50 <div className="h-24 flex items-end justify-center">{children}</div> 51 <div className="text-[10px] leading-tight text-center w-full truncate opacity-70">{name}</div> 52 <div 53 className="text-[10px] leading-tight text-center w-full truncate font-bold" 54 style={{ color: fallback ? '#f87171' : ACCENT }} 55 > 56 {absent ? 'not here' : value} 57 </div> 58 </div> 59 ); 60} 61 62function Meter({ share }) { 63 return ( 64 <div className="relative w-1.5 h-24 rounded-sm bg-background overflow-hidden"> 65 <div 66 className="absolute inset-x-0 bottom-0" 67 style={{ 68 height: `${share * 100}%`, 69 transition: 'height 100ms linear', 70 background: `linear-gradient(to top, ${ACCENT} 0%, ${ACCENT} 70%, #facc15 85%, #f87171 100%)`, 71 backgroundSize: '100% 6rem', 72 backgroundPosition: 'bottom', 73 }} 74 /> 75 </div> 76 ); 77} 78 79function Fader({ share, next, level }) { 80 const cap = (s, ghost) => ( 81 <div 82 className="absolute left-1/2 w-7 h-3 -ml-3.5 -mb-1.5 rounded-sm border" 83 style={{ 84 bottom: `${s * 100}%`, 85 transition: `bottom ${GLIDE}`, 86 background: ghost ? 'transparent' : 'var(--foreground)', 87 borderColor: ghost ? ACCENT : 'var(--foreground)', 88 borderStyle: ghost ? 'dashed' : 'solid', 89 opacity: ghost ? 0.7 : 1, 90 boxShadow: ghost ? undefined : `inset 0 -2px 0 ${ACCENT}`, 91 }} 92 /> 93 ); 94 return ( 95 <div className="flex items-end gap-1.5 h-24"> 96 <div className="relative w-8 h-24"> 97 <div className="absolute left-1/2 -ml-px w-0.5 inset-y-0 bg-background" /> 98 {next !== null && cap(next, true)} 99 {cap(share, false)} 100 </div> 101 {level !== undefined && <Meter share={level} />} 102 </div> 103 ); 104} 105 106// A knob: `share` 0..1 along the sweep; `notches` marks a selector's options. 107function Knob({ share, next, notches = 0, fill = false }) { 108 const angle = (s) => -ARC / 2 + s * ARC; 109 const r = 20; 110 const point = (deg, len) => { 111 const rad = ((deg - 90) * Math.PI) / 180; 112 return [24 + len * Math.cos(rad), 24 + len * Math.sin(rad)]; 113 }; 114 const arc = (from, to, len) => { 115 const [x1, y1] = point(from, len); 116 const [x2, y2] = point(to, len); 117 return `M ${x1} ${y1} A ${len} ${len} 0 ${to - from > 180 ? 1 : 0} 1 ${x2} ${y2}`; 118 }; 119 return ( 120 <svg width="48" height="48" viewBox="0 0 48 48" className="mb-4"> 121 <path d={arc(-ARC / 2, ARC / 2, r)} fill="none" stroke="var(--background)" strokeWidth="3" /> 122 {fill && share > 0.005 && <path d={arc(-ARC / 2, angle(share), r)} fill="none" stroke={ACCENT} strokeWidth="3" />} 123 {Array.from({ length: notches }, (_, i) => { 124 const [x, y] = point(angle(notches > 1 ? i / (notches - 1) : 0.5), r + 2); 125 return <circle key={i} cx={x} cy={y} r="1.5" fill="var(--foreground)" opacity="0.5" />; 126 })} 127 {next !== null && ( 128 <line 129 x1="24" 130 y1="24" 131 x2="24" 132 y2="8" 133 stroke={ACCENT} 134 strokeWidth="2" 135 strokeDasharray="2 2" 136 opacity="0.7" 137 style={{ 138 transform: `rotate(${angle(next)}deg)`, 139 transformOrigin: '24px 24px', 140 transition: `transform ${GLIDE}`, 141 }} 142 /> 143 )} 144 <g 145 style={{ 146 transform: `rotate(${angle(share)}deg)`, 147 transformOrigin: '24px 24px', 148 transition: `transform ${GLIDE}`, 149 }} 150 > 151 {/* a dark cap with a rim, so the accent pointer reads on it (yellow on white did not) */} 152 <circle cx="24" cy="24" r="14" fill="var(--lineHighlight)" stroke="var(--foreground)" strokeOpacity="0.35" strokeWidth="1.5" /> 153 <line x1="24" y1="24" x2="24" y2="11" stroke={ACCENT} strokeWidth="3" strokeLinecap="round" /> 154 </g> 155 </svg> 156 ); 157} 158 159function Channel({ q, a, next, levels }) { 160 if (q.type === 'score') { 161 const top = q.criteria.length - 1; 162 const share = a ? a.score / top : 0; 163 const part = levels?.parts ? meterShare(levels.parts[q.name]) : 0; 164 return ( 165 <Strip 166 name={q.name} 167 value={a ? q.criteria[Math.round(a.score)] : '—'} 168 title={`${q.instructions}${a ? ` → ${a.score.toFixed(2)} of ${top}` : ''}`} 169 fallback={isDefault(a)} 170 absent={a?.absent} 171 > 172 <Fader share={share} next={next ? next.score / top : null} level={levels ? part : undefined} /> 173 </Strip> 174 ); 175 } 176 if (q.type === 'noul') { 177 return ( 178 <Strip 179 name={q.name} 180 value={a ? `${Math.round(a.noul * 100)}%` : '—'} 181 title={q.instructions} 182 fallback={isDefault(a)} 183 absent={a?.absent} 184 > 185 <Knob share={a?.noul ?? 0} next={next ? next.noul : null} fill /> 186 </Strip> 187 ); 188 } 189 // a level as a choice (out/back/full, narrowed per section with `allow`): 190 // a fader, in the ladder's order 191 const options = Object.keys(q.criteria); 192 if (options.every((o) => o in LADDER)) { 193 const part = levels?.parts ? meterShare(levels.parts[q.name]) : 0; 194 return ( 195 <Strip 196 name={q.name} 197 value={a?.choice ?? '—'} 198 title={`${q.instructions}${a ? ` → ${a.choice}` : ''}`} 199 fallback={isDefault(a)} 200 absent={a?.absent} 201 > 202 <Fader 203 share={a ? LADDER[a.choice] : 0} 204 next={next ? LADDER[next.choice] : null} 205 level={levels ? part : undefined} 206 /> 207 </Strip> 208 ); 209 } 210 // a choice: a selector with a notch per option 211 const at = (c) => (options.length > 1 ? Math.max(options.indexOf(c), 0) / (options.length - 1) : 0.5); 212 return ( 213 <Strip 214 name={q.name} 215 value={a?.choice ?? '—'} 216 title={`${q.instructions}${a ? ` → ${a.choice}: ${q.criteria[a.choice] ?? ''}` : ''}`} 217 fallback={isDefault(a)} 218 absent={a?.absent} 219 > 220 <Knob share={a ? at(a.choice) : 0} next={next ? at(next.choice) : null} notches={options.length} /> 221 </Strip> 222 ); 223} 224 225function Lcd({ q, a, next }) { 226 return ( 227 <div className="flex flex-col justify-center shrink-0 w-36 h-full gap-1" title={q.instructions}> 228 <div className="text-[10px] opacity-70">{q.name}</div> 229 <div 230 className="rounded px-2 py-2 font-bold text-lg leading-none truncate bg-background" 231 style={{ color: ACCENT, textShadow: `0 0 6px ${ACCENT}` }} 232 > 233 {a?.choice ?? '—'} 234 </div> 235 <div className="text-[10px] opacity-70 truncate">{next ? `next: ${next.choice}` : ' '}</div> 236 </div> 237 ); 238} 239 240// The desk before any song with a jev() has played: the section's LCD, 241// blank channels and the master meter, at rest. 242const SKELETON_CHANNELS = 6; 243function MixerSkeleton() { 244 return ( 245 <div 246 className="border-t border-muted bg-lineHighlight text-foreground font-mono shrink-0 overflow-x-auto" 247 style={{ opacity: 0.45 }} 248 aria-label="Jev's mixer, waiting for a song" 249 > 250 <div className="flex items-stretch gap-3 px-3 py-2 min-w-max"> 251 <div className="flex flex-col justify-center shrink-0 text-xs pr-1"> 252 <span className="font-bold" style={{ color: ACCENT }}> 253 🎛 Jev 254 </span> 255 <span className="text-[10px] opacity-60">no song</span> 256 </div> 257 <Lcd q={{ name: 'section', instructions: 'the section Jev picks' }} a={null} next={null} /> 258 {Array.from({ length: SKELETON_CHANNELS }, (_, i) => ( 259 <Strip key={i} name="—" value=" "> 260 <Fader share={0} next={null} /> 261 </Strip> 262 ))} 263 <Strip name="master" value="—"> 264 <div className="flex gap-1 items-end h-24"> 265 <Meter share={0} /> 266 <Meter share={0} /> 267 </div> 268 </Strip> 269 </div> 270 </div> 271 ); 272} 273 274export function JevMixer({ context }) { 275 const booth = useBooth(); 276 const cycle = usePlayhead(context.editorRef, context.started); 277 const levels = useLevels(context.started); 278 if (!booth) return <MixerSkeleton />; 279 const now = playing(booth, cycle); 280 // stopped: the desk rests where the song starts 281 const values = now?.decision.values ?? booth.decisions[0]?.values ?? {}; 282 const upcoming = now ? booth.decisions[now.segment + 1] : undefined; 283 const nextOf = (name) => (upcoming && upcoming.status !== 'asking' ? (upcoming.values[name] ?? null) : null); 284 const qs = questionsOf(booth); 285 const form = qs.find((q) => q.type === 'form'); 286 return ( 287 <div 288 className="border-t border-muted bg-lineHighlight text-foreground font-mono shrink-0 overflow-x-auto" 289 style={{ opacity: now ? 1 : 0.6, transition: 'opacity 300ms' }} 290 aria-label="Jev's mixer" 291 > 292 <div className="flex items-stretch gap-3 px-3 py-2 min-w-max"> 293 <div className="flex flex-col justify-center shrink-0 text-xs pr-1"> 294 <span className="font-bold" style={{ color: ACCENT }}> 295 🎛 Jev 296 </span> 297 <span className="text-[10px] opacity-60">{now ? `cycle ${Math.floor(cycle) + 1}` : 'stopped'}</span> 298 </div> 299 {form && <Lcd q={form} a={values[form.name]} next={nextOf(form.name)} />} 300 {qs 301 .filter((q) => q.type !== 'form') 302 .map((q) => ( 303 <Channel key={q.name} q={q} a={values[q.name]} next={nextOf(q.name)} levels={levels} /> 304 ))} 305 <Strip name="master" value={levels ? `${Math.round(20 * Math.log10(Math.max(levels.rms, 1e-6)))} dB` : '—'}> 306 <div className="flex gap-1 items-end h-24"> 307 <Meter share={meterShare(levels)} /> 308 <Meter 309 share={levels ? Math.min(Math.max((20 * Math.log10(Math.max(levels.peak, 1e-6)) + 60) / 60, 0), 1) : 0} 310 /> 311 </div> 312 </Strip> 313 </div> 314 </div> 315 ); 316}