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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/boxcollect.mjs +276 -0
- package/server/boxes.mjs +85 -9
- package/server/main.mjs +5 -0
- package/server/settingswords.mjs +4 -0
- package/web/app.js +43 -1
- package/web/kit.js +13 -0
- package/web/style.css +1 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
|
@@ -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,
|
|
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
|
-
|
|
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, {
|
package/server/settingswords.mjs
CHANGED
|
@@ -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
|
|