jevstrudel.git / website / src / jev / soundPick.mjs
1// Jev picking a sound for what someone describes (the MCP's ask_jev_sound,
2// tabTools.mjs): a Choice whose options are the sounds tab's sound names.
3//
4// TypeSafe takes at most 255 options a Choice, and the tab can hold
5// thousands of sounds. More than that are asked in banks, as Krug's JevLM
6// does (JevLM-Open, jevlm/multipass.py): the candidates split into
7// ceil(n / 255) banks of near-equal size, one question per bank, several
8// questions to a request within the relay's size; each bank's few likeliest
9// go on to one final question among them. So the final probabilities are
10// among those finalists, not every sound, and the answer says so. More
11// than MAX_BANKS banks is refused rather than asked: narrow first.
12import { askJev } from './ask.mjs';
13
14export const MAX_OPTIONS = 255; // TypeSafe's own limit for a Choice
15export const MAX_BANKS = 16; // as JevLM's MAX_VOCABULARY_OPTIONS: 4080 sounds
16export const FINALISTS_PER_BANK = 3;
17// a request's questions, within the relay's 64 KiB with room for the state
18export const MAX_REQUEST_BYTES = 48 * 1024;
19export const DESCRIPTION_MAX = 280;
20
21const INSTRUCTIONS =
22  'A musician describes the sound they want for a Strudel song (`want`). Choose the sound that fits it best, ' +
23  'judging by its name (drum machines are "<machine>_<sound>", gm_ names are General MIDI instruments, wt_ ' +
24  'are wavetables), its kind and how many variants it has.';
25
26// What Jev reads of a sound: its name, its kind, its variants.
27const describe = (s) => `${s.name}: ${s.category}${s.type && s.type !== 'sample' ? ` ${s.type}` : ''}${s.variants > 1 ? `, ${s.variants} variants` : ''}`.slice(0, 200);
28const criteriaOf = (sounds) => Object.fromEntries(sounds.map((s) => [s.name, describe(s)]));
29
30// `n` candidates in ceil(n / max) banks, sizes differing by at most one.
31export function banks(items, max = MAX_OPTIONS) {
32  const count = Math.ceil(items.length / max);
33  const size = Math.floor(items.length / count);
34  const extra = items.length % count;
35  const out = [];
36  let at = 0;
37  for (let i = 0; i < count; i++) {
38    const length = size + (i < extra ? 1 : 0);
39    out.push(items.slice(at, at + length));
40    at += length;
41  }
42  return out;
43}
44
45const choice = (sounds) => ({ type: 'choice', instructions: INSTRUCTIONS, criteria: criteriaOf(sounds) });
46const bytes = (x) => new TextEncoder().encode(JSON.stringify(x)).length;
47
48// Questions packed into requests of at most MAX_REQUEST_BYTES each.
49export function pack(questions) {
50  const requests = [];
51  let current = {};
52  let size = 0;
53  for (const [key, q] of Object.entries(questions)) {
54    const b = bytes({ [key]: q });
55    if (size && size + b > MAX_REQUEST_BYTES) {
56      requests.push(current);
57      current = {};
58      size = 0;
59    }
60    current[key] = q;
61    size += b;
62  }
63  if (size) requests.push(current);
64  return requests;
65}
66
67// The likeliest `k` of an answer's options, most likely first.
68function likeliest(answer, sounds, k) {
69  const known = new Map(sounds.map((s) => [s.name, s]));
70  const ranked = Object.entries(answer?.probabilities ?? {})
71    .filter(([name, p]) => known.has(name) && Number.isFinite(p))
72    .sort((a, b) => b[1] - a[1])
73    .map(([name]) => known.get(name));
74  // the chosen one first, even if its probabilities disagree
75  const chosen = known.get(answer?.choice);
76  const out = chosen ? [chosen, ...ranked.filter((s) => s !== chosen)] : ranked;
77  return out.slice(0, k);
78}
79
80// Jev's pick among `sounds` (soundEntry()s) for `want`: resolves to
81// { picks: [{ ...sound, p }], confidence, candidates, banks, calls, scope },
82// `scope` 'all' when every candidate was in the final question, else
83// 'finalists'. `ask` is askJev (tests pass their own).
84export async function pickSound(sounds, want, { ask = askJev, top = 3 } = {}) {
85  const text = String(want ?? '').trim().slice(0, DESCRIPTION_MAX);
86  if (!text) throw new Error('say what sound you want');
87  if (sounds.length < 2) {
88    if (sounds.length === 1) return { picks: [{ ...sounds[0], p: 1 }], confidence: null, candidates: 1, banks: 0, calls: 0, scope: 'all' };
89    throw new Error('no sounds match; list_sounds shows what the tab has');
90  }
91  const state = { want: text };
92  let calls = 0;
93  let finalists = sounds;
94  let bankCount = 1;
95  if (sounds.length > MAX_OPTIONS) {
96    const split = banks(sounds);
97    bankCount = split.length;
98    if (bankCount > MAX_BANKS) {
99      throw new Error(`${sounds.length} sounds are too many to ask about (at most ${MAX_BANKS * MAX_OPTIONS}); narrow with category or filter`);
100    }
101    const questions = Object.fromEntries(split.map((bank, i) => [`bank${i}`, choice(bank)]));
102    const answers = {};
103    for (const request of pack(questions)) {
104      calls++;
105      Object.assign(answers, await ask(request, state));
106    }
107    finalists = split.flatMap((bank, i) => likeliest(answers[`bank${i}`], bank, FINALISTS_PER_BANK));
108    if (finalists.length < 2) throw new Error('Jev did not answer about the sounds');
109  }
110  calls++;
111  const { sound: answer } = await ask({ sound: choice(finalists) }, state);
112  const picks = likeliest(answer, finalists, top).map((s) => ({ ...s, p: answer.probabilities?.[s.name] ?? null }));
113  if (!picks.length || picks[0].name !== answer?.choice) throw new Error(`Jev answered "${String(answer?.choice).slice(0, 64)}", which is not one of the sounds`);
114  return {
115    picks,
116    confidence: typeof answer.confidence === 'number' ? answer.confidence : null,
117    candidates: sounds.length,
118    banks: bankCount > 1 ? bankCount : 0,
119    calls,
120    scope: bankCount > 1 ? 'finalists' : 'all',
121  };
122}