jevstrudel.git / website / src / jev / JevMixer.jsx
JevMixer.jsx316 lines · 11.7 KB · raw
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}