clavarium 0.2.365-next.1 → 0.2.366-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.365-next.1",
3
+ "version": "0.2.366-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.365-next.1",
9
+ "version": "0.2.366-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.365-next.1",
3
+ "version": "0.2.366-next.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
@@ -0,0 +1,276 @@
1
+ /* A COLLECT SESSION (07-11): a box's false wakes gathered on purpose, to train its next wake-word model (v3).
2
+ *
3
+ * For a set time (an empty home: the owner's go), each time the box listens nothing goes to its conversation and
4
+ * nothing is answered: the wake clip (wake.clip: the exact 1.6 s the box sends, 16 kHz 16-bit mono) and the first
5
+ * seconds after it are kept here, with one line of what is known -- no one labels them, no one listens:
6
+ *
7
+ * <dir>/<YYYY-MM-DD>/<n>.wav the wake clip, as sent (UTC day)
8
+ * <dir>/<YYYY-MM-DD>/<n>-suite.wav the next AFTER_MAX seconds at most (a radio: more of the same)
9
+ * <dir>/<YYYY-MM-DD>/index.jsonl { n, at, box, tag, reason, score, heard, word, voice, end, station, clip, after }
10
+ * <dir>/state.json { <boxId>: { from, until, tag, after, radio, volume, station, ... } }
11
+ * <dir>/ended.json { <boxId>: the last session, ended (shown on the box's card: « Exporter ») }
12
+ *
13
+ * RADIO (optional): the box asks the Google Home beside it, aloud, for one French station after another
14
+ * (« Ok Google, mets France Inter »), every `every` minutes, and « Ok Google, stop » at the end. No wake for
15
+ * QUIET_WARN_MS after a station was asked: said in the log (the speaker may not have obeyed).
16
+ *
17
+ * Off by itself at its end time, at COUNT_MAX entries or BYTES_MAX bytes on disk. Started and stopped by the
18
+ * operator only (/api/boxes/<id>/collect, behind the sign-on), never by a tool. exportZip(): the clips, laid out
19
+ * as the training notebook (v2, cell 7 bis) takes its negative examples: folders of 16 kHz WAV.
20
+ */
21
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, renameSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { Refusal } from './conversations.mjs';
24
+ import { wavOf } from './boxes.mjs';
25
+
26
+ export const COUNT_MAX = 1500;
27
+ export const BYTES_MAX = 300 * 1024 * 1024;
28
+ export const AFTER_DEFAULT = 8;
29
+ export const AFTER_MAX = 30;
30
+ export const HOURS_MAX = 12;
31
+ export const EVERY_DEFAULT = 60;
32
+ export const QUIET_WARN_MS = 20 * 60_000;
33
+ export const GRACE_MS = 5 * 60_000;
34
+ export const VOLUME_DEFAULT = 4; /* the Google Home's own scale, 0-10: a radio heard across the room, not loud */
35
+ /* French talk and music stations, as a Google Home knows them by name. */
36
+ export const STATIONS = ['France Inter', 'France Info', 'RTL', 'Europe 1', 'France Culture', 'Nostalgie', 'FIP', 'RMC'];
37
+ export const TAG = /^[a-z0-9-]{1,20}$/;
38
+ const BOX_ID = /^[0-9a-f-]{36}$/;
39
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
40
+ const dayOf = (t) => new Date(t).toISOString().slice(0, 10);
41
+ const timeOf = (v) => { const t = typeof v === 'number' ? v : Date.parse(String(v ?? '')); return Number.isFinite(t) ? t : NaN; };
42
+
43
+ export const stationPhrase = (name) => `Ok Google, mets ${name}`;
44
+ export const volumePhrase = (level) => `Ok Google, mets le volume à ${level}`;
45
+ export const STOP_PHRASE = 'Ok Google, stop';
46
+
47
+ export class BoxCollect {
48
+ #dir; #now; #log; #state; #countMax; #bytesMax; #usage = null; #next = new Map(); #ended = {};
49
+
50
+ constructor({ dir, now = () => Date.now(), log = () => {}, countMax = COUNT_MAX, bytesMax = BYTES_MAX }) {
51
+ this.#dir = dir; this.#now = now; this.#log = log; this.#countMax = countMax; this.#bytesMax = bytesMax;
52
+ try { this.#state = JSON.parse(readFileSync(join(dir, 'state.json'), 'utf8')); } catch { this.#state = {}; }
53
+ if (!this.#state || typeof this.#state !== 'object') this.#state = {};
54
+ try { this.#ended = JSON.parse(readFileSync(join(dir, 'ended.json'), 'utf8')) ?? {}; } catch { this.#ended = {}; }
55
+ }
56
+
57
+ #save() {
58
+ mkdirSync(this.#dir, { recursive: true });
59
+ const file = join(this.#dir, 'state.json');
60
+ writeFileSync(`${file}.tmp`, JSON.stringify(this.#state, null, 1)); renameSync(`${file}.tmp`, file);
61
+ }
62
+
63
+ /* What is on disk, counted once, then kept up to date. */
64
+ #used() {
65
+ if (this.#usage) return this.#usage;
66
+ let count = 0; let bytes = 0;
67
+ for (const day of existsSync(this.#dir) ? readdirSync(this.#dir).filter((d) => DAY.test(d)) : []) {
68
+ for (const f of readdirSync(join(this.#dir, day))) {
69
+ try { bytes += statSync(join(this.#dir, day, f)).size; } catch { /* gone meanwhile */ }
70
+ if (/^\d+\.wav$/.test(f)) count++;
71
+ }
72
+ }
73
+ this.#usage = { count, bytes };
74
+ return this.#usage;
75
+ }
76
+
77
+ /* {from?, until, tag?, after?, radio?, every?, volume?, stations?} -> the state. */
78
+ start(boxId, body = {}) {
79
+ if (!BOX_ID.test(String(boxId))) throw new Refusal(400, 'Not a box id.');
80
+ const now = this.#now();
81
+ const from = body.from == null ? now : timeOf(body.from);
82
+ const until = timeOf(body.until);
83
+ if (!Number.isFinite(from) || !Number.isFinite(until)) throw new Refusal(400, 'An end time, please (until: ISO date).');
84
+ if (until <= Math.max(now, from)) throw new Refusal(400, 'The end time is already past.');
85
+ if (until - Math.max(now, from) > HOURS_MAX * 3_600_000) throw new Refusal(400, `${HOURS_MAX} hours at most.`);
86
+ const tag = String(body.tag ?? 'radio');
87
+ if (!TAG.test(tag)) throw new Refusal(400, 'A tag of a-z, 0-9 and -, 20 characters at most.');
88
+ const after = Math.round(Number(body.after ?? AFTER_DEFAULT));
89
+ if (!Number.isFinite(after) || after < 0 || after > AFTER_MAX) throw new Refusal(400, `after: 0 to ${AFTER_MAX} seconds.`);
90
+ const every = Math.round(Number(body.every ?? EVERY_DEFAULT));
91
+ if (!Number.isFinite(every) || every < 10 || every > 240) throw new Refusal(400, 'every: 10 to 240 minutes.');
92
+ const volume = Math.round(Number(body.volume ?? VOLUME_DEFAULT));
93
+ if (!Number.isFinite(volume) || volume < 1 || volume > 10) throw new Refusal(400, 'volume: 1 to 10 (the speaker\'s scale).');
94
+ const stations = Array.isArray(body.stations) && body.stations.length
95
+ ? body.stations.map((s) => String(s).replace(/[^\p{L}\p{N} '-]/gu, '').trim().slice(0, 40)).filter(Boolean).slice(0, 12)
96
+ : STATIONS;
97
+ if (!stations.length) throw new Refusal(400, 'No station.');
98
+ this.#state[boxId] = {
99
+ from: new Date(from).toISOString(), until: new Date(until).toISOString(), tag, after, radio: body.radio === true,
100
+ every, volume, stations, startedAt: new Date(now).toISOString(),
101
+ station: null, stationAt: null, stationIndex: -1, volumeSaid: false, lastWakeAt: null, warnedAt: null,
102
+ kept: 0, wakes: 0, answers: [], warnings: [],
103
+ };
104
+ this.#save();
105
+ this.#log(`voice box ${boxId.slice(0, 8)}: collect on (${tag}) until ${new Date(until).toISOString()}${body.radio === true ? ', radio' : ''}`);
106
+ return this.state(boxId);
107
+ }
108
+
109
+ /* Off: by the operator, at its end, or full. Returns what was on (to say « stop » to the speaker), or null. */
110
+ stop(boxId, why = 'asked') {
111
+ const s = this.#state[boxId];
112
+ if (!s) return null;
113
+ delete this.#state[boxId]; this.#save();
114
+ this.#log(`voice box ${String(boxId).slice(0, 8)}: collect off (${why}), ${s.kept} kept`);
115
+ this.#ended = { ...this.#ended, [boxId]: { ...s, endedAt: new Date(this.#now()).toISOString(), why } };
116
+ try { writeFileSync(join(this.#dir, 'ended.json'), JSON.stringify(this.#ended)); } catch { /* shown until a restart only */ }
117
+ return s;
118
+ }
119
+
120
+ /* On now: begun, not yet ended. With the radio, GRACE_MS more: the radio plays until the box has told the
121
+ speaker to stop (the clock waits for a box busy listening), and its wakes must not reach the AI meanwhile. */
122
+ on(boxId) {
123
+ const s = this.#state[boxId];
124
+ if (!s) return false;
125
+ const now = this.#now();
126
+ return now >= Date.parse(s.from) && now < Date.parse(s.until) + (s.radio ? GRACE_MS : 0);
127
+ }
128
+
129
+ state(boxId) {
130
+ const s = this.#state[boxId]; const u = this.#used();
131
+ if (!s) return { on: false, last: this.#ended[boxId] ?? null, count: u.count, bytes: u.bytes };
132
+ return { on: this.on(boxId), ...s, count: u.count, bytes: u.bytes, countMax: this.#countMax, bytesMax: this.#bytesMax };
133
+ }
134
+
135
+ /* One listen of the box, during a session: kept (wake, button, knock), or null when full or off. */
136
+ keep(boxId, { clip = null, after = Buffer.alloc(0), reason = '', score = null, heard = null, word = null, voice = null, end = '' } = {}) {
137
+ const s = this.#state[boxId];
138
+ if (!s || !this.on(boxId)) return null;
139
+ const now = this.#now();
140
+ if (reason === 'wake') { s.wakes++; s.lastWakeAt = new Date(now).toISOString(); }
141
+ const u = this.#used();
142
+ const tail = after.subarray(0, (s.after * 32_000) & ~1);
143
+ const size = (clip?.length ? clip.length + 44 : 0) + (tail.length ? tail.length + 44 : 0);
144
+ if (u.count >= this.#countMax || u.bytes + size > this.#bytesMax) { this.#save(); return null; }
145
+ const day = dayOf(now); const dir = join(this.#dir, day);
146
+ mkdirSync(dir, { recursive: true });
147
+ const n = this.#nextOf(day);
148
+ const line = {
149
+ n, at: new Date(now).toISOString(), box: boxId, tag: s.tag, reason: String(reason).slice(0, 12),
150
+ score: Number.isFinite(score) ? score : null, heard: heard == null ? null : String(heard).slice(0, 200),
151
+ word: typeof word === 'boolean' ? word : null, voice: Number.isFinite(voice) && voice > 0 ? Math.round(voice) : null,
152
+ end: String(end).slice(0, 12), station: s.radio ? s.station : null, clip: null, after: null,
153
+ };
154
+ if (clip?.length) { writeFileSync(join(dir, `${n}.wav`), wavOf(clip)); line.clip = `${n}.wav`; }
155
+ if (tail.length) { writeFileSync(join(dir, `${n}-suite.wav`), wavOf(tail)); line.after = `${n}-suite.wav`; }
156
+ appendFileSync(join(dir, 'index.jsonl'), `${JSON.stringify(line)}\n`);
157
+ u.count++; u.bytes += size; s.kept++;
158
+ this.#save();
159
+ return line;
160
+ }
161
+ #nextOf(day) {
162
+ if (!this.#next.has(day)) {
163
+ let n = 0;
164
+ try { for (const l of readFileSync(join(this.#dir, day, 'index.jsonl'), 'utf8').split('\n')) { try { n = Math.max(n, JSON.parse(l).n); } catch { /* a torn line */ } } } catch { /* a new day */ }
165
+ this.#next.set(day, n);
166
+ }
167
+ const n = this.#next.get(day) + 1; this.#next.set(day, n);
168
+ return n;
169
+ }
170
+ full() { const u = this.#used(); return u.count >= this.#countMax || u.bytes >= this.#bytesMax - 400_000; } /* no room for one more (a clip and AFTER_DEFAULT s) */
171
+
172
+ /* What the speaker answered when asked for a station (the box listened after it spoke). */
173
+ answered(boxId, text) {
174
+ const s = this.#state[boxId]; if (!s) return;
175
+ s.answers = [...s.answers, { at: new Date(this.#now()).toISOString(), station: s.station, text: String(text).slice(0, 120) }].slice(-20);
176
+ this.#save();
177
+ }
178
+
179
+ /* THE CLOCK (every half minute): what is due now, for one box -- { end } | { say, station? } | { warn } | null.
180
+ online: the box is connected; busy: it is listening now (a phrase then waits for the next tick). */
181
+ due(boxId, { online = true, busy = false } = {}) {
182
+ const s = this.#state[boxId];
183
+ if (!s) return null;
184
+ const now = this.#now();
185
+ if (this.full()) return { end: 'full', radio: s.radio };
186
+ if (now >= Date.parse(s.until) && (!busy || !online || now >= Date.parse(s.until) + GRACE_MS - 60_000)) return { end: 'time', radio: s.radio };
187
+ if (now >= Date.parse(s.until)) return null;
188
+ if (!s.radio || !online || busy || now < Date.parse(s.from)) return null;
189
+ if (!s.volumeSaid) { s.volumeSaid = true; this.#save(); return { say: volumePhrase(s.volume) }; }
190
+ if (s.stationIndex < 0 || now - Date.parse(s.stationAt) >= s.every * 60_000) {
191
+ s.stationIndex = (s.stationIndex + 1) % s.stations.length;
192
+ s.station = s.stations[s.stationIndex]; s.stationAt = new Date(now).toISOString(); s.warnedAt = null;
193
+ this.#save();
194
+ return { say: stationPhrase(s.station), station: s.station };
195
+ }
196
+ const quietSince = Math.max(Date.parse(s.stationAt), Date.parse(s.lastWakeAt ?? '') || 0);
197
+ if (!s.warnedAt && now - quietSince >= QUIET_WARN_MS) {
198
+ s.warnedAt = new Date(now).toISOString();
199
+ const text = `no wake for ${Math.round((now - quietSince) / 60_000)} min since ${s.station} was asked: the Google Home may not have obeyed`;
200
+ s.warnings = [...s.warnings, { at: s.warnedAt, station: s.station, text }].slice(-20);
201
+ this.#save();
202
+ return { warn: text };
203
+ }
204
+ return null;
205
+ }
206
+ boxes() { return Object.keys(this.#state); }
207
+
208
+ /* THE EXPORT: one zip, laid out for the training notebook (v2, cell 7 bis: a folder of 16 kHz WAV per source):
209
+ <tag>/<day>-<n>.wav the wake clips (1.6 s): the hard negatives
210
+ <tag>_suite/<day>-<n>.wav what came after them: more negative speech and music
211
+ index.csv file, at, reason, score, heard, word, voice, end, station
212
+ LISEZMOI.txt how the notebook takes them
213
+ Only the box's wakes (not a button or a knock) of that tag; days: all, or those given. */
214
+ exportZip({ tag = 'radio', days = null } = {}) {
215
+ if (!TAG.test(String(tag))) throw new Refusal(400, 'Not a tag.');
216
+ const files = []; const rows = [['file', 'suite', 'at', 'reason', 'score', 'heard', 'word', 'voice', 'end', 'station'].join(',')];
217
+ const wanted = Array.isArray(days) && days.length ? new Set(days.filter((d) => DAY.test(String(d)))) : null;
218
+ const csv = (v) => (v == null ? '' : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
219
+ for (const day of existsSync(this.#dir) ? readdirSync(this.#dir).filter((d) => DAY.test(d) && (!wanted || wanted.has(d))).sort() : []) {
220
+ let lines = [];
221
+ try { lines = readFileSync(join(this.#dir, day, 'index.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { continue; }
222
+ for (const l of lines) {
223
+ if (l.tag !== tag || l.reason !== 'wake' || !l.clip) continue;
224
+ const name = `${day}-${l.n}.wav`;
225
+ try { files.push({ name: `${tag}/${name}`, data: readFileSync(join(this.#dir, day, l.clip)) }); } catch { continue; }
226
+ let suite = '';
227
+ if (l.after) { try { files.push({ name: `${tag}_suite/${name}`, data: readFileSync(join(this.#dir, day, l.after)) }); suite = `${tag}_suite/${name}`; } catch { /* the clip alone */ } }
228
+ rows.push([`${tag}/${name}`, suite, l.at, l.reason, l.score, l.heard, l.word, l.voice, l.end, l.station].map(csv).join(','));
229
+ }
230
+ }
231
+ files.push({ name: 'index.csv', data: Buffer.from(`${rows.join('\n')}\n`) });
232
+ files.push({ name: 'LISEZMOI.txt', data: Buffer.from(readme(tag, rows.length - 1)) });
233
+ return { zip: zipOf(files, this.#now()), clips: rows.length - 1 };
234
+ }
235
+ }
236
+
237
+ const readme = (tag, n) => `${n} faux réveils du boîtier (session « ${tag} »), sans étiquetage : personne à la maison, tout réveil est un faux.
238
+
239
+ ${tag}/ les 1,6 s qui ont réveillé le boîtier (wav 16 kHz, 16 bits, mono) : les contre-exemples durs
240
+ ${tag}_suite/ les secondes d'après (même format) : de la radio française en plus
241
+ index.csv score d'éveil, réécoute du serveur (word = le mot entendu), niveau de voix, station
242
+
243
+ Dans le carnet v2 (cellule 7 bis) :
244
+ 1. décompresser ce zip dans /content ;
245
+ 2. ajouter à la boucle c) : ('${tag}', 'negative_datasets/${tag}'), ('${tag}_suite', 'negative_datasets/${tag}_suite') ;
246
+ 3. ajouter à config["features"] (cellule 8) deux entrées « truth: False, truncation_strategy: random »,
247
+ ${tag} avec penalty_weight 2.0 (comme near_words), ${tag}_suite avec sampling_weight 6.0.
248
+ `;
249
+
250
+ /* A ZIP, stored (WAV does not compress): enough for a few hundred megabytes, no dependency. */
251
+ const CRC = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
252
+ export function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; }
253
+ export function zipOf(files, at = Date.now()) {
254
+ const d = new Date(at);
255
+ const time = (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | (d.getUTCSeconds() >> 1);
256
+ const date = ((d.getUTCFullYear() - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate();
257
+ const parts = []; const central = []; let offset = 0;
258
+ for (const f of files) {
259
+ const name = Buffer.from(f.name, 'utf8'); const crc = crc32(f.data);
260
+ const local = Buffer.alloc(30);
261
+ local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(0, 8);
262
+ local.writeUInt16LE(time, 10); local.writeUInt16LE(date, 12); local.writeUInt32LE(crc, 14);
263
+ local.writeUInt32LE(f.data.length, 18); local.writeUInt32LE(f.data.length, 22); local.writeUInt16LE(name.length, 26); local.writeUInt16LE(0, 28);
264
+ const c = Buffer.alloc(46);
265
+ c.writeUInt32LE(0x02014b50, 0); c.writeUInt16LE(20, 4); c.writeUInt16LE(20, 6); c.writeUInt16LE(0x0800, 8); c.writeUInt16LE(0, 10);
266
+ c.writeUInt16LE(time, 12); c.writeUInt16LE(date, 14); c.writeUInt32LE(crc, 16); c.writeUInt32LE(f.data.length, 20); c.writeUInt32LE(f.data.length, 24);
267
+ c.writeUInt16LE(name.length, 28); c.writeUInt32LE(offset, 42);
268
+ parts.push(local, name, f.data); central.push(c, name);
269
+ offset += 30 + name.length + f.data.length;
270
+ }
271
+ const size = central.reduce((s, b) => s + b.length, 0);
272
+ const end = Buffer.alloc(22);
273
+ end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10);
274
+ end.writeUInt32LE(size, 12); end.writeUInt32LE(offset, 16);
275
+ return Buffer.concat([...parts, ...central, end]);
276
+ }
package/server/boxes.mjs CHANGED
@@ -15,13 +15,14 @@
15
15
  *
16
16
  * Both are under /c/: the edge lets them through without sign-on, as the
17
17
  * device door. Nothing is kept of the audio, here or on the box -- but the wake word's own
18
- * 1.6 s of a box set to learn it (wakelearn.mjs).
18
+ * 1.6 s of a box set to learn it (wakelearn.mjs), and what a box hears in a COLLECT session (boxcollect.mjs).
19
19
  */
20
20
  import { spawn as spawnProcess } from 'node:child_process';
21
21
  import { createWriteStream, mkdirSync, readFileSync, rmSync, statSync } from 'node:fs';
22
22
  import { randomBytes, randomUUID, createHash, timingSafeEqual } from 'node:crypto';
23
23
  import { Refusal } from './conversations.mjs';
24
24
  import { WIRE, cutoffOf, scoreOf } from './wakelearn.mjs';
25
+ import { STOP_PHRASE } from './boxcollect.mjs';
25
26
 
26
27
  export const CODE_MINUTES = 10;
27
28
  export const PAIR_PATH = '/c/api/box/pair';
@@ -283,12 +284,12 @@ export function piecesOf(pcm, { least = 8, most = 14 } = {}) {
283
284
 
284
285
  export class Boxes {
285
286
  #file; #codes = new Map(); #links = new Map(); #tries = []; #now;
286
- #exists; #hear; #say; #note; #speak; #log; #changed; #images = new Map(); #origin; #spawn; #fetch; #streams = new Map(); #recordDir; #parts; #keep; #react; #learn;
287
+ #exists; #hear; #say; #note; #speak; #log; #changed; #images = new Map(); #origin; #spawn; #fetch; #streams = new Map(); #recordDir; #parts; #keep; #react; #learn; #collect; #stopSay = new Map();
287
288
 
288
289
  /* file: a JsonFile holding [{ id, name, conversation, keyHash, firmware, createdAt, seenAt }].
289
290
  exists(conversation), hear(wav) -> text, say(conversation, text, room), speak(text) -> PCM 16 kHz. */
290
- constructor({ file, exists = () => true, hear, say, note = null, speak, changed = () => {}, origin = () => null, now = () => Date.now(), log = () => {}, spawn = spawnProcess, fetcher = fetch, recordDir = null, parts = null, keep = null, react = async () => null, learn = null }) {
291
- this.#react = react; this.#learn = learn;
291
+ constructor({ file, exists = () => true, hear, say, note = null, speak, changed = () => {}, origin = () => null, now = () => Date.now(), log = () => {}, spawn = spawnProcess, fetcher = fetch, recordDir = null, parts = null, keep = null, react = async () => null, learn = null, collect = null }) {
292
+ this.#react = react; this.#learn = learn; this.#collect = collect;
292
293
  this.#spawn = spawn; this.#fetch = fetcher; this.#recordDir = recordDir; this.#parts = parts; this.#keep = keep;
293
294
  this.#file = file; this.#changed = changed; this.#origin = origin; this.#exists = exists; this.#hear = hear; this.#say = say; this.#note = note; this.#speak = speak; this.#now = now; this.#log = log;
294
295
  }
@@ -424,13 +425,13 @@ export class Boxes {
424
425
  /* SPEAKING TO ANOTHER DEVICE in the room (a Google Home: « OK Google, baisse la clim »): said as given, its
425
426
  call included, then the box listens; what it hears within twenty seconds comes back to the conversation as
426
427
  that device's answer, not as the person's words (31-10: the box set the heating that way). */
427
- async speakTo(id, text) {
428
+ async speakTo(id, text, { listen = true } = {}) {
428
429
  const link = this.#links.get(id);
429
430
  if (!link) throw new Refusal(409, 'This voice box is not connected.');
430
431
  const words = forSpeech(text, { callOthers: true });
431
432
  if (!words) throw new Refusal(400, 'Nothing to say.');
432
- link.play(await this.#speak(words), 'listen');
433
- link.relay = this.#now();
433
+ link.play(await this.#speak(words), listen ? 'listen' : null);
434
+ if (listen) link.relay = this.#now();
434
435
  this.#log(`voice box ${link.box().name}: spoke to a device in the room`);
435
436
  return { ok: true };
436
437
  }
@@ -778,7 +779,7 @@ export class Boxes {
778
779
  return;
779
780
  }
780
781
  if (m.t === WIRE.clip && m.edge === 'start') { link.clip = { parts: [], bytes: 0, score: scoreOf(m.score) }; /* none (before 0.1.49): unknown, left out of tuning */ return; }
781
- if (m.t === WIRE.clip && m.edge === 'end' && link.clip) { const { parts, score } = link.clip; link.clip = null; link.wakeCheck = this.#checkWake(link, Buffer.concat(parts), { score }); return; }
782
+ if (m.t === WIRE.clip && m.edge === 'end' && link.clip) { const { parts, score } = link.clip; link.clip = null; link.clipPcm = Buffer.concat(parts); link.clipScore = score; link.wakeCheck = this.#checkWake(link, link.clipPcm, { score }); return; }
782
783
  if (m.t === WIRE.near && m.edge === 'start') { link.near = { parts: [], bytes: 0, score: scoreOf(m.score) }; return; }
783
784
  if (m.t === WIRE.near && m.edge === 'end' && link.near) { const { parts, score } = link.near; link.near = null; void this.#checkNear(link, Buffer.concat(parts), score); return; }
784
785
  if (m.t === 'event' && m.name === 'wake.own') { this.#log(`voice box ${box.name}: heard its own voice say the wake word, ignored`); return; }
@@ -790,8 +791,17 @@ export class Boxes {
790
791
  this.#log(`voice box ${box.name}: a sentence while recording here, the recording closed`);
791
792
  r.out.end(() => { void this.#recorded(link, r); });
792
793
  }
794
+ if (m.t === 'listen.start') { link.listening = true; link.startScore = scoreOf(m.score); link.clipPcm = null; link.clipScore = null; }
793
795
  if (m.t === 'listen.start') { link.why = whyOf(m); link.wakeCheck = null; link.voice = NaN; link.endReason = ''; link.startReason = String(m.reason ?? ''); link.clipSeen = false; } /* its clip, if any, comes after listen.start: an older check never carries over */
794
796
  if (m.t === 'listen.start') { if (this.stop(box.id)) this.#log(`voice box ${box.name}: stopped playing, someone speaks`); sentence = []; size = 0; return; }
797
+ if (m.t === 'listen.end') link.listening = false;
798
+ /* A COLLECT SESSION (boxcollect.mjs): nothing goes to the conversation, nothing is answered; kept. */
799
+ if (m.t === 'listen.end' && sentence && this.#collect?.on(box.id)) {
800
+ const pcm = Buffer.concat(sentence); sentence = null;
801
+ link.endReason = String(m.reason ?? ''); link.voice = Number(m.ep?.voice);
802
+ void this.#collected(link, pcm);
803
+ return;
804
+ }
795
805
  if (m.t === 'listen.end' && sentence && m.reason === 'nobody') {
796
806
  /* Listened again, nobody spoke: a conversation ends there, in silence. */
797
807
  sentence = null;
@@ -873,7 +883,8 @@ export class Boxes {
873
883
  const ok = wakeHeard(heard);
874
884
  /* Counted, and kept when the box learns (the clip only: never the sentence), with the box's score. */
875
885
  const box = link.box();
876
- try { this.#learn?.checked(box.id, pcm, ok, { keep: box.wake === 'learn', heard, score, near }); } catch (error) { this.#log(`voice box ${box.name}: wake clip not kept (${error.message})`); }
886
+ /* Not during a collect session: a radio asked for on purpose would count as the room's false wakes (tuning). */
887
+ if (!this.#collect?.on(box.id)) try { this.#learn?.checked(box.id, pcm, ok, { keep: box.wake === 'learn', heard, score, near }); } catch (error) { this.#log(`voice box ${box.name}: wake clip not kept (${error.message})`); }
877
888
  return { heard, ok };
878
889
  }
879
890
  /* A NEAR MISS (wake.near): checked the same way, only when the box learns; one at a time; nothing said. */
@@ -973,6 +984,58 @@ export class Boxes {
973
984
  link.doingSaid = false;
974
985
  }
975
986
 
987
+ /* COLLECT (boxcollect.mjs): a listen of the box, kept, never sent on. The box is told « done » at once. After
988
+ it asked the speaker for a station (reason follow), what it heard is the speaker's answer: dictated, logged. */
989
+ async #collected(link, pcm) {
990
+ const box = link.box();
991
+ link.send({ t: 'cue', name: 'done' });
992
+ const reason = link.startReason;
993
+ const clip = link.clipPcm; const score = link.clipScore ?? link.startScore; link.clipPcm = null;
994
+ if (reason === 'follow') {
995
+ link.wakeCheck = null;
996
+ if (pcm.length < 16_000) return;
997
+ try {
998
+ const text = String(await this.#hear(wavOf(pcm.subarray(0, 20 * 32_000)), box.lang) ?? '').trim();
999
+ this.#log(`voice box ${box.name}: collect, the speaker answered « ${text.slice(0, 60)} »`);
1000
+ this.#collect.answered(box.id, text);
1001
+ } catch (error) { this.#log(`voice box ${box.name}: collect, the speaker's answer not dictated (${error.message})`); }
1002
+ return;
1003
+ }
1004
+ const check = link.wakeCheck ? await link.wakeCheck.catch(() => null) : null; link.wakeCheck = null;
1005
+ let line = null;
1006
+ try { line = this.#collect.keep(box.id, { clip, after: pcm, reason, score, heard: check?.heard ?? null, word: check?.ok ?? null, voice: link.voice, end: link.endReason }); } catch (error) { this.#log(`voice box ${box.name}: collect, not kept (${error.message})`); return; }
1007
+ this.#log(line ? `voice box ${box.name}: collect, ${reason} #${line.n} kept (mot ${check ? (check.ok ? '\u2713' : '\u2717') : '?'}, ${link.endReason})` : `voice box ${box.name}: collect, ${reason} not kept (full, or the session ended)`);
1008
+ }
1009
+ collectStart(id, body) { this.#box(id); return this.#collecting().start(id, body); }
1010
+ collectStop(id) {
1011
+ this.#box(id);
1012
+ const was = this.#collecting().stop(id, 'asked');
1013
+ if (was?.radio) this.#stopSay.set(id, this.#now());
1014
+ void this.collectTick();
1015
+ return this.#collect.state(id);
1016
+ }
1017
+ collectState(id) { this.#box(id); return this.#collecting().state(id); }
1018
+ collectExport(tag, days) { return this.#collecting().exportZip({ tag, days }); }
1019
+ #collecting() { if (!this.#collect) throw new Refusal(501, 'This server collects no wake clips.'); return this.#collect; }
1020
+ /* Its clock (every half minute, main.mjs): stations asked, the end, « Ok Google, stop » once the box is free. */
1021
+ async collectTick() {
1022
+ if (!this.#collect) return;
1023
+ const tell = async (id, text, listen) => { await this.speakTo(id, text, { listen }); this.#log(`voice box ${id.slice(0, 8)}: collect, said « ${text} »`); };
1024
+ for (const id of this.#collect.boxes()) {
1025
+ const link = this.#links.get(id);
1026
+ const d = this.#collect.due(id, { online: Boolean(link), busy: Boolean(link?.listening) });
1027
+ if (!d) continue;
1028
+ if (d.end) { const was = this.#collect.stop(id, d.end); if (was?.radio) this.#stopSay.set(id, this.#now()); continue; }
1029
+ if (d.warn) { this.#log(`voice box ${id.slice(0, 8)}: collect, ${d.warn}`); continue; }
1030
+ try { await tell(id, d.say, true); } catch (error) { this.#log(`voice box ${id.slice(0, 8)}: collect, « ${d.say} » not said (${error.message})`); }
1031
+ }
1032
+ for (const [id, at] of this.#stopSay) {
1033
+ const link = this.#links.get(id);
1034
+ if (link && !link.listening) { this.#stopSay.delete(id); try { await tell(id, STOP_PHRASE, false); } catch (error) { this.#log(`voice box ${id.slice(0, 8)}: collect, the speaker not stopped (${error.message})`); } }
1035
+ else if (this.#now() - at > 10 * 60_000) { this.#stopSay.delete(id); this.#log(`voice box ${id.slice(0, 8)}: collect, the speaker could not be told to stop (box ${link ? 'busy' : 'offline'})`); }
1036
+ }
1037
+ }
1038
+
976
1039
  /* Wires the box link on an HTTP server: the key in Authorization, else 401. */
977
1040
  attach(server, WebSocketServer) {
978
1041
  const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });
@@ -1015,6 +1078,10 @@ export class Boxes {
1015
1078
  * POST /api/boxes/<id>/wake-erase every clip of the box deleted -> the same
1016
1079
  * POST /api/boxes/<id>/wake-improve -> 202 {queued, at, tuning}: the threshold tuned now; nothing better, the
1017
1080
  * trainer asked (queued)
1081
+ * GET /api/boxes/<id>/collect -> the COLLECT session (boxcollect.mjs): {on, from, until, tag, kept, ...}
1082
+ * POST /api/boxes/<id>/collect {until, from?, tag?, after?, radio?, every?, volume?, stations?} -> the same;
1083
+ * {on: false}: stopped now
1084
+ * GET /api/boxes/collect-export?tag=radio&day=YYYY-MM-DD -> a zip of the wake clips kept (any day: all)
1018
1085
  */
1019
1086
  export async function handleBoxes(request, response, boxes, originOk, headers = {}, qr = null) {
1020
1087
  const { pathname } = new URL(request.url ?? '/', 'http://local');
@@ -1023,6 +1090,14 @@ export async function handleBoxes(request, response, boxes, originOk, headers =
1023
1090
  if (request.method === 'GET' && pathname === '/api/boxes') return send(200, boxes.all());
1024
1091
  const asked = request.method === 'GET' && /^\/api\/boxes\/([0-9a-f-]{36})\/wake$/.exec(pathname);
1025
1092
  if (asked) return send(200, boxes.wakeStats(asked[1]));
1093
+ const collecting = request.method === 'GET' && /^\/api\/boxes\/([0-9a-f-]{36})\/collect$/.exec(pathname);
1094
+ if (collecting) return send(200, boxes.collectState(collecting[1]));
1095
+ if (request.method === 'GET' && pathname === '/api/boxes/collect-export') {
1096
+ const q = new URL(request.url ?? '/', 'http://local').searchParams;
1097
+ const { zip, clips } = boxes.collectExport(q.get('tag') ?? 'radio', q.getAll('day'));
1098
+ response.writeHead(200, { ...headers, 'content-type': 'application/zip', 'content-length': zip.length, 'content-disposition': `attachment; filename="faux-reveils-${q.get('tag') ?? 'radio'}.zip"`, 'x-clips': String(clips) }).end(zip);
1099
+ return true;
1100
+ }
1026
1101
  if (request.method !== 'POST') return false;
1027
1102
  if (!originOk(request.headers)) return send(403, { error: 'This request does not come from this site.' });
1028
1103
  let text = '';
@@ -1060,6 +1135,7 @@ export async function handleBoxes(request, response, boxes, originOk, headers =
1060
1135
  case 'wake-mode': return send(200, boxes.wakeMode(id, String(body.mode ?? '')));
1061
1136
  case 'wake-erase': return send(200, boxes.wakeErase(id));
1062
1137
  case 'wake-improve': return send(202, boxes.wakeImprove(id));
1138
+ case 'collect': return send(200, body.on === false ? boxes.collectStop(id) : boxes.collectStart(id, body));
1063
1139
  default: return false;
1064
1140
  }
1065
1141
  } catch (error) {
package/server/main.mjs CHANGED
@@ -80,6 +80,7 @@ import { Bundles } from './bundle.mjs';
80
80
  import { migrateData } from './dataformat.mjs';
81
81
  import { VmWatch } from './vmwatch.mjs';
82
82
  import { WakeLearn } from './wakelearn.mjs';
83
+ import { BoxCollect } from './boxcollect.mjs';
83
84
  import { Boxes, handleBoxes, voiceboxConnector, syncTargets, PAIR_PATH as BOX_PAIR_PATH, FIRMWARE_PATH as BOX_FIRMWARE_PATH } from './boxes.mjs';
84
85
  import { Pocket } from './pocket.mjs';
85
86
  import { Speech } from './speech.mjs';
@@ -667,6 +668,8 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
667
668
  react: (said) => voice.reaction(said),
668
669
  /* The wake word that learns: each box's wake clips, when it is set so (wakelearn.mjs). */
669
670
  learn: new WakeLearn({ dir: `${dataDir}/wake-learn` }),
671
+ /* A collect session: a box's false wakes kept on purpose, nothing answered (boxcollect.mjs; started by the owner). */
672
+ collect: new BoxCollect({ dir: `${dataDir}/box-collect`, log: (line) => events.add({ kind: 'server', text: line }) }),
670
673
  recordDir: `${dataDir}/recordings`, parts: (wav) => voice.transcribeParts(wav), keep: (name, file, text) => talk.keepText(name, file, text),
671
674
  hear: (wav, lang) => hearBox(voice, wav, lang), /* any language heard; one neither the box's (its page's) nor English heard again in the box's (voice.mjs) */
672
675
  /* Said as the person's own message, the room named (one speaker for now). */
@@ -733,6 +736,8 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
733
736
  boxes.attach(server, WebSocketServer);
734
737
  /* The wake word's weekly threshold tuning (Sunday from 03:00, each box's own time): looked at every 10 minutes. */
735
738
  const wakeWeekly = setInterval(() => { try { boxes.wakeWeekly(); } catch (error) { console.error(`wake tuning: ${error.message}`); } }, 10 * 60_000); wakeWeekly.unref?.(); server.on('close', () => clearInterval(wakeWeekly));
739
+ /* A collect session's clock: the stations asked, its end, the speaker told to stop (boxcollect.mjs). */
740
+ const collectTick = setInterval(() => { boxes.collectTick().catch((error) => console.error(`box collect: ${error.message}`)); }, 30_000); collectTick.unref?.(); server.on('close', () => clearInterval(collectTick));
736
741
  for (const c of all.list()) talk.speakers(c.name, boxes.speakersOf(c.name));
737
742
  try { syncTargets(tethers.store(), boxes.all()); } catch (error) { console.error(`voice boxes in Tether: ${error.message}`); }
738
743
  bridge = attachBridge(server, {
@@ -17,6 +17,10 @@ export const SETTINGS_WORDS_FR = {
17
17
  'set-free-of': '{free} libres sur {total}', 'set-left-of': '{free} restants sur {total}', 'set-highest': 'au plus {n} % en 1 h',
18
18
  'set-core': '1 cœur', 'set-cores': '{n} cœurs',
19
19
  'set-update-out': '{v} est sortie', 'set-up-to-date': 'À jour · {v}', 'set-wifi-on': 'Wi-Fi · {name}', 'set-wifi-off': 'Wi-Fi non utilisé', 'set-cable': 'Câble',
20
+ /* A box's learning session (boxcollect.mjs): the radio, its false wakes kept. */
21
+ 'set-collect': 'Séance d\'apprentissage (radio)', 'set-collect-from': 'de', 'set-collect-to': 'à',
22
+ 'set-collect-go': 'Programmer', 'set-collect-stop': 'Arrêter', 'set-collect-scheduled': 'Programmée de {from} à {to}',
23
+ 'set-collect-running': 'En cours : {n} réveils gardés', 'set-collect-ended': 'Terminée : {n} réveils', 'set-collect-export': 'Exporter',
20
24
  'set-not-well': 'ne va pas bien', 'set-box-away': '{name} hors ligne',
21
25
  'set-just-now': 'à l\'instant', 'set-min-ago': 'il y a {n} min', 'set-h-ago': 'il y a {n} h', 'set-days-ago': 'il y a {n} jours',
22
26
  'set-on': 'Activé', 'set-off': 'Désactivé', 'set-none': 'Aucun', 'set-changed': '{n} modifiés', 'set-as-given': 'Comme livrés',
package/web/app.js CHANGED
@@ -10660,6 +10660,48 @@
10660
10660
  void getJson(`/boxes/${b.id}/wake`, null).then((w) => { if (w) paint(w); });
10661
10661
  return part;
10662
10662
  }
10663
+ /* A LEARNING SESSION (boxcollect.mjs): the box's false wakes kept, nothing answered, the Google Home playing
10664
+ the radio -- programmed here in one tap (today, local hours); its one line says where it stands. */
10665
+ function boxCollect(b) {
10666
+ const part = el('div', 'box-wake box-collect');
10667
+ const at = (hhmm, after = 0) => { const d = new Date(); const [h, m] = String(hhmm).split(':').map(Number); d.setHours(h, m || 0, 0, 0); if (d.getTime() <= after) d.setDate(d.getDate() + 1); return d; };
10668
+ const from = el('input'); from.type = 'time'; from.value = '09:00'; from.setAttribute('aria-label', uiSaid('set-collect-from', 'from'));
10669
+ const to = el('input'); to.type = 'time'; to.value = '17:00'; to.setAttribute('aria-label', uiSaid('set-collect-to', 'to'));
10670
+ const go = el('button', 'primary'); go.type = 'button';
10671
+ const line = el('div', 'box-wake-lines');
10672
+ const row = el('div', 'action-row box-wake-row');
10673
+ row.append(el('span', '', uiSaid('set-collect', 'Learning session (radio)')), el('span', '', uiSaid('set-collect-from', 'from')), from, el('span', '', uiSaid('set-collect-to', 'to')), to, go);
10674
+ part.append(row, line);
10675
+ const hour = (iso) => new Date(iso).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
10676
+ let busy = false;
10677
+ const paint = (c) => {
10678
+ const m = KIT.collectModel(c);
10679
+ busy = m.state === 'scheduled' || m.state === 'running';
10680
+ go.textContent = busy ? uiSaid('set-collect-stop', 'Stop') : uiSaid('set-collect-go', 'Schedule');
10681
+ const hhmm = (iso) => { const d = new Date(iso); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
10682
+ if (busy) { from.value = hhmm(m.from); to.value = hhmm(m.until); }
10683
+ from.disabled = busy; to.disabled = busy; go.disabled = false;
10684
+ line.replaceChildren();
10685
+ if (m.state === 'scheduled') line.append(el('small', 'hint', uiSaid('set-collect-scheduled', 'Scheduled from {from} to {to}', { from: hour(m.from), to: hour(m.until) })));
10686
+ if (m.state === 'running') line.append(el('small', 'hint', uiSaid('set-collect-running', 'Running: {n} wakes kept', { n: m.kept })));
10687
+ if (m.state === 'ended') {
10688
+ const out = el('a', '', uiSaid('set-collect-export', 'Export')); out.href = `${API}/boxes/collect-export?tag=radio`; out.setAttribute('download', '');
10689
+ const said = el('small', 'hint', `${uiSaid('set-collect-ended', 'Ended: {n} wakes', { n: m.kept })} \u2014 `); said.append(out);
10690
+ line.append(said);
10691
+ }
10692
+ };
10693
+ go.addEventListener('click', async () => {
10694
+ go.disabled = true;
10695
+ try {
10696
+ if (busy) { paint(await post(`${API}/boxes/${b.id}/collect`, { on: false })); return; }
10697
+ const start = at(from.value); const end = at(to.value, start.getTime());
10698
+ paint(await post(`${API}/boxes/${b.id}/collect`, { from: start.toISOString(), until: end.toISOString(), tag: 'radio', radio: true }));
10699
+ } catch (error) { go.disabled = false; line.replaceChildren(el('small', 'warn', error.message)); }
10700
+ });
10701
+ paint({});
10702
+ void getJson(`/boxes/${b.id}/collect`, null).then((c) => { if (c) paint(c); });
10703
+ return part;
10704
+ }
10663
10705
  async function paintBoxes() {
10664
10706
  const list = await getJson('/boxes', []); await loadRooms();
10665
10707
  const rows = $('box-list'); rows.replaceChildren();
@@ -10704,7 +10746,7 @@
10704
10746
  const volRow = el('label', 'box-volume'); volRow.append('Volume ', vol, volSaid);
10705
10747
  const acts = el('div', 'action-row'); acts.append(whereRow, place, volRow, rename, remove);
10706
10748
  card.append(head, acts);
10707
- card.append(boxWake(b));
10749
+ card.append(boxWake(b), boxCollect(b));
10708
10750
  /* TESTED FROM HERE (01-11): its sound -- it says a test sentence -- and its microphone -- it listens
10709
10751
  once, and what it heard shows here, not in the conversation. */
10710
10752
  if (b.online) {
package/web/kit.js CHANGED
@@ -789,7 +789,20 @@
789
789
  return box;
790
790
  }
791
791
 
792
+ /* A BOX'S COLLECT SESSION (boxcollect.mjs), from GET /api/boxes/<id>/collect, in one state for its one line:
793
+ none · scheduled (from, until) · running (kept) · ended (kept). */
794
+ function collectModel(c, now = Date.now()) {
795
+ if (c?.until) {
796
+ const from = Date.parse(c.from); const until = Date.parse(c.until);
797
+ if (!c.on && from > now) return { state: 'scheduled', from: c.from, until: c.until };
798
+ return { state: c.on || now < until ? 'running' : 'ended', kept: c.kept ?? 0, from: c.from, until: c.until };
799
+ }
800
+ if (c?.last) return { state: 'ended', kept: c.last.kept ?? 0, from: c.last.from, until: c.last.until };
801
+ return { state: 'none' };
802
+ }
803
+
792
804
  window.clavariumKit = {
805
+ collectModel,
793
806
  AssetIcon, AssetCard, Holders, RightsBadge, RightsPicker, AssetList, assetListModel, AccessList, accessModel, AssetPanel, AssetDelete, RIGHT_SAME,
794
807
  ASSET_ICONS, ASSET_KINDS, RIGHTS, RIGHT_LEVELS, rightsKey, rightsFor, rightOf, storedOf, lightest, rightWord,
795
808
  shareSlices, ShareDonut, ShareKey,
package/web/style.css CHANGED
@@ -2191,6 +2191,7 @@ body.as-dragging #assets-rows .as-row { touch-action: none; }
2191
2191
  .box-wake { margin-top: 8px; }
2192
2192
  .box-wake-row { align-items: center; }
2193
2193
  .box-wake-lines { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
2194
+ .box-collect a { color: var(--brass-ink); }
2194
2195
  .box-wake-sure { display: inline-flex; align-items: center; gap: 6px; }
2195
2196
  .box-wake-sure[hidden] { display: none; }
2196
2197