create-pmos 0.133.0 → 0.134.0

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,14 +1,18 @@
1
- /* pm/shell-recording.js — the recording indicator beside the bell (console3 wave E2b).
1
+ /* pm/shell-recording.js — the Record control in console3's top bar (console3 wave E2b; the Record control port).
2
2
  *
3
3
  * mountRecording(host) / disposeRecording(host)
4
4
  *
5
- * It starts nothing. It reads the real recorder through note-dock.js (recordState) and offers only the controls the Record control in
6
- * the top bar already has: open the note, mark a moment (highlightNow), pause, resume and stop (pauseNow, resumeNow, stopNow). When
7
- * nothing is recording or paused the group is hidden. While one is, a red dot sits on the Notes tab (`[data-fr-dest="notes"]` in the
8
- * frame's bar; a phone tab `[data-dest="notes"]` if one is drawn). Lifted: console3.html `.recg` and `recCtl` (lines 916 to 924, 4385
9
- * to 4400). The group shows the recorder's state under reduced motion as a static dot. The clock is the recorder's own start time.
5
+ * The control is always there, idle included, with the same capabilities as the main app's Record control (pm/topbar.js, pm/note-dock.js):
6
+ * New (an untitled note that records at once), an arrow that lists today's meetings that have not started (a pick makes a note linked to that
7
+ * calendar event and records it), and, while a meeting records or is paused, a chip that names it with Highlight, Pause or Resume, and Stop
8
+ * (Stop asks first and offers "Pause instead"). All of it goes through pm/note-dock.js, which owns the real recorder, so the audio capture of
9
+ * pm/view-notes.js runs here without the main app's Notes place on screen. The chip opens the note in the main app, because console3 has no
10
+ * Notes place. While one meeting records, a red dot sits on the Calendar tab (`[data-c3-tab="cal"]`) and on a Notes tab if a frame draws one.
11
+ * On /c3.html?demo=1 the control drives a SIMULATED recorder (see below): it opens no microphone and saves nothing, and it shows only while
12
+ * the simulation runs. Lifted: console3.html `.recg` and `recCtl` (lines 916 to 924, 4385 to 4400). The clock is the recorder's own start time.
10
13
  */
11
14
  import { el } from './dom.js';
15
+ import { button } from './primitives.js';
12
16
  import { ensureC3Css, c3ic } from './pm-work-view.js';
13
17
  import * as dock from './note-dock.js';
14
18
  import { recView, clockOf } from './shell-model.js';
@@ -20,10 +24,10 @@ import { toast } from './c3-toast.js';
20
24
  const TAB = '[data-fr-dest="notes"], #mobNav [data-dest="notes"], [data-c3-tab="cal"]';
21
25
 
22
26
  /* ── demo mode: a SIMULATED recorder ──────────────────────────────────────────────────────────────────────────────────────────────
23
- * A page cannot record (nothing on a page may record). On /c3.html?demo=1 the Record button of the Calendar's During and After stages drives
24
- * this stand-in, so the top bar shows the mockup's recording group: a pulsing dot, the clock, mark moment, pause or resume, and stop.
25
- * It keeps a clock (performance.now: the demo freezes Date) and nothing else. No microphone is opened and nothing is saved.
26
- * Real mode never reaches it: `isDemo()` reads the page's data-c3-demo mark, which only the demo sets. */
27
+ * On /c3.html?demo=1 the Record button of the Calendar's During and After stages drives this stand-in, so the top bar shows the mockup's
28
+ * recording group: a pulsing dot, the clock, mark moment, pause or resume, and stop. It keeps a clock (performance.now: the demo freezes
29
+ * Date) and nothing else. No microphone is opened and nothing is saved. Real mode never reaches it: `isDemo()` reads the page's
30
+ * data-c3-demo mark, which only the demo sets. */
27
31
  export const isDemo = () => typeof document !== 'undefined' && !!document.querySelector('[data-c3-demo]');
28
32
  const SIM = { mode: 'idle', id: 'sim', title: '', started: 0, pausedAt: 0 };
29
33
  const tick = () => performance.now();
@@ -50,7 +54,8 @@ export function mountRecording(host, opts) {
50
54
  if (!host) return;
51
55
  disposeRecording(host);
52
56
  ensureC3Css();
53
- const d = (opts && opts.dock) || (isDemo() ? simDock : dock);
57
+ if (!(opts && opts.dock) && !isDemo()) return mountReal(host);
58
+ const d = (opts && opts.dock) || simDock;
54
59
  const nowMs = () => (d.now ? d.now() : Date.now());
55
60
  const grp = el('span', { class: 'recg', role: 'group', 'aria-label': 'Recording', hidden: true, 'data-shell': 'recording' });
56
61
  host.insertBefore(grp, host.firstChild);
@@ -80,7 +85,8 @@ export function mountRecording(host, opts) {
80
85
  else {
81
86
  rec.clock = el('span', { class: 'recclk', 'data-shell-rec-clock': '' });
82
87
  const open = el('button', { type: 'button', title: (v.mode === 'recording' ? 'Recording: ' : 'Paused: ') + v.title + '. Open the live note', 'aria-label': 'Open the note: ' + v.title, 'data-shell-rec-open': '', onclick: () => d.openDock(st.id) },
83
- el('span', { class: 'recdot' + (v.dot === 'static' ? ' static' : ''), 'data-dot': v.dot }), rec.clock, el('span', { class: 'rt' }, v.title));
88
+ el('span', { class: 'recdot' + (v.dot === 'static' ? ' static' : ''), 'data-dot': v.dot }), rec.clock, el('span', { class: 'rt' }, v.title),
89
+ ...(eng.text ? [el('span', { class: 'c3rec__eng', 'data-fr-rec-engine': '', title: 'The live transcript: engine and language (Notes settings)' }, eng.text)] : []));
84
90
  const kids = [open];
85
91
  if (v.canMark) kids.push(btn('Mark this moment', 'highlighter', () => d.highlightNow(), 'data-shell-rec-mark'));
86
92
  if (v.canPause) kids.push(btn('Pause', 'pause', () => d.pauseNow(), 'data-shell-rec-pause'));
@@ -103,8 +109,112 @@ export function disposeRecording(host) {
103
109
  const rec = host && live.get(host);
104
110
  if (!rec) return;
105
111
  clearInterval(rec.timer);
112
+ if (rec.off) rec.off();
106
113
  if (rec.onSim) window.removeEventListener('c3-sim-rec', rec.onSim);
107
114
  rec.grp.remove();
108
115
  for (const t of document.querySelectorAll(TAB)) { const dot = t.querySelector('.shell-recdot'); if (dot) dot.remove(); }
109
116
  live.delete(host);
110
117
  }
118
+
119
+ /* ── real mode: the always-visible Record control ─────────────────────────────────────────────────────────────────────────────────── */
120
+
121
+ const NS_SVG = 'http://www.w3.org/2000/svg';
122
+ const glyph = (d) => { const s = document.createElementNS(NS_SVG, 'svg'); s.setAttribute('class', 'ic sm'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); const p = document.createElementNS(NS_SVG, 'path'); p.setAttribute('d', d); s.appendChild(p); return s; };
123
+ const RGL = { highlighter: () => c3ic('flag', 'sm'), pause: () => glyph('M8 5v14M16 5v14'), play: () => c3ic('play', 'sm'), square: () => c3ic('stop', 'sm'), mic: () => c3ic('mic', 'sm'), down: () => glyph('M6 9l6 6 6-6') };
124
+
125
+ /** The chip opens the note. console3 has no Notes place, so it opens the main app, where the full note is. */
126
+ const openNote = () => { try { window.open('/', '_blank', 'noopener'); } catch { /* a blocked pop-up */ } };
127
+
128
+ /* The engine and language the live transcript uses, on the chip (notes-recording-reliability): a meeting transcribed in the wrong
129
+ * language because of a setting is visible while it records. Read once per recording from GET /api/notes/engine. */
130
+ const ENGINE = { Whisper: 'whisper', Qwen: 'qwen' };
131
+ async function engineLabel() {
132
+ try {
133
+ const { pmGet } = await import('./http.js');
134
+ const r = await pmGet('/api/notes/engine');
135
+ const w = r && r.ok ? r.whisper : null;
136
+ if (!w || !w.live) return '';
137
+ const name = Object.keys(ENGINE).find((k) => ENGINE[k] === w.live) || w.live;
138
+ const lang = ((w.languages || []).find((x) => x.value === w.language) || {}).label || w.language || '';
139
+ return name + (lang ? ' · ' + lang : '');
140
+ } catch { return ''; }
141
+ }
142
+
143
+ function mountReal(host) {
144
+ ensureC3Css();
145
+ let ready = false;
146
+ const wrap = el('span', { class: 'c3rec', 'data-fr': 'record', 'data-shell': 'recording' });
147
+ host.insertBefore(wrap, host.firstChild);
148
+ const rec = { grp: wrap, key: '', clock: null };
149
+ const tabDot = (on) => {
150
+ for (const t of document.querySelectorAll(TAB)) {
151
+ let dot = t.querySelector('.shell-recdot');
152
+ if (!dot) { dot = el('span', { class: 'shell-recdot', 'aria-hidden': 'true' }); t.appendChild(dot); }
153
+ dot.hidden = !on;
154
+ }
155
+ };
156
+ const btn = (label, name, fn, attrs) => button({ label: RGL[name](), variant: 'ghost', onClick: fn, attrs: { title: label, 'aria-label': label, ...attrs } });
157
+ const off = (b, why) => { b.setAttribute('aria-disabled', 'true'); b.classList.add('c3rec__off'); b.title = why; b.setAttribute('aria-label', why); return b; };
158
+ const chipText = (st) => (st.mode === 'recording' ? (st.started ? clockOf(Date.now() - st.started) : '') : st.until ? 'paused · ' + Math.max(1, Math.ceil((st.until - Date.now()) / 60000)) + ' min left' : 'paused');
159
+
160
+ /** The arrow beside New: Untitled note, then today's meetings that have not started and have no note, the next one marked. */
161
+ async function openUpcoming(anchor) {
162
+ const { menu } = await import('./primitives.js');
163
+ const links = await import('./notes-links.js');
164
+ const evs = links.upcomingWithoutNote(await links.upcomingToday());
165
+ const fmt = (e, i) => {
166
+ const mins = Math.max(1, Math.round((Date.parse(e.start) - Date.now()) / 60000));
167
+ const inText = mins < 60 ? 'in ' + mins + ' min' : 'in ' + Math.floor(mins / 60) + ' h' + (mins % 60 ? ' ' + (mins % 60) + ' min' : '');
168
+ return { label: e.title, hint: e.time || '', detail: [i === 0 ? 'Next' : null, inText, e.with || null].filter(Boolean).join(' · '), attrs: { 'data-fr-rec-event': e.id || '' }, onSelect: () => dock.recordEvent(e) };
169
+ };
170
+ menu({ anchor, ariaLabel: 'Record a new meeting', items: [{ label: 'Untitled note', icon: 'plus', detail: 'not linked to a meeting', attrs: { 'data-fr-rec-untitled': '' }, onSelect: () => dock.recordNew() }, { sep: true },
171
+ ...(evs.length ? evs.map(fmt) : [{ label: 'No more meetings today', attrs: { 'data-fr-rec-none': '' }, onSelect: () => {} }])] });
172
+ }
173
+
174
+ let eng = { id: null, text: '' };
175
+ const sync = () => {
176
+ let st; try { st = dock.recordState(); } catch { st = { mode: 'idle' }; }
177
+ if (st.mode !== 'idle' && eng.id !== st.id) { eng = { id: st.id, text: '' }; const want = st.id; engineLabel().then((t) => { if (eng.id === want && t) { eng.text = t; rec.key = ''; sync(); } }); }
178
+ const hl = st.mode === 'recording' && dock.highlightBusy ? dock.highlightBusy() : null;
179
+ const key = [st.mode, st.id, st.title, hl ? hl.state + hl.open : '', eng.text].join(':');
180
+ if (key !== rec.key) {
181
+ rec.key = key; rec.clock = null;
182
+ const recording = st.mode === 'recording';
183
+ const kids = [];
184
+ if (st.mode !== 'idle') {
185
+ const v = recView(st, reducedMotion());
186
+ rec.clock = el('span', { class: 'recclk', 'data-fr-rec-clock': '' }, chipText(st));
187
+ const chip = button({ variant: 'ghost', onClick: () => openNote(st.id),
188
+ attrs: { class: 'c3rec__chip', title: (recording ? 'Recording: ' : 'Paused: ') + v.title + '. Open the note in the main app', 'aria-label': (recording ? 'Recording ' : 'Paused: ') + v.title + '. Open the note.', 'data-fr-rec': st.mode, 'data-shell-rec-open': '' },
189
+ label: [el('span', { class: 'recdot' + (v.dot === 'static' ? ' static' : ''), 'data-dot': v.dot }), rec.clock, el('span', { class: 'rt' }, v.title),
190
+ ...(eng.text ? [el('span', { class: 'c3rec__eng', 'data-fr-rec-engine': '', title: 'The live transcript: engine and language (Notes settings)' }, eng.text)] : [])] });
191
+ const group = [chip];
192
+ if (recording) {
193
+ group.push(btn(hl ? 'Highlight in progress: show it' : 'Highlight the last 30 seconds and what is said next', 'highlighter', () => dock.highlightNow(), { 'data-fr-rec-highlight': hl ? hl.state : '', 'data-shell-rec-mark': '' }),
194
+ btn('Pause (you can resume within 1 hour)', 'pause', () => dock.pauseNow(), { 'data-fr-rec-pause': '', 'data-shell-rec-pause': '' }));
195
+ } else group.push(btn('Resume ' + st.title, 'play', () => dock.resumeNow(), { 'data-fr-rec-resume': '', 'data-shell-rec-resume': '' }));
196
+ group.push(btn('Stop for good (cannot be resumed)', 'square', () => dock.stopNow(), { 'data-fr-rec-stop': '', 'data-shell-rec-stop': '' }));
197
+ kids.push(el('span', { class: 'recg c3rec__live' + (recording ? '' : ' paused'), role: 'group', 'aria-label': (recording ? 'Recording ' : 'Paused: ') + st.title }, group));
198
+ }
199
+ const why = 'Pause or stop the meeting being recorded to start a new one';
200
+ const mic = button({ variant: 'ghost', label: [RGL.mic(), el('span', { class: 'c3rec__lbl' }, 'Record')], onClick: (e) => { if (!recording) dock.recordNew(e.currentTarget); },
201
+ attrs: { title: 'Record an untitled note', 'aria-label': 'Record an untitled note', 'data-fr-rec-new': '' } });
202
+ const arrow = btn('Record for an upcoming meeting', 'down', (e) => { if (!recording) openUpcoming(e.currentTarget); }, { 'data-fr-rec-upcoming': '', 'aria-haspopup': 'menu' });
203
+ if (recording) { off(mic, why); off(arrow, why); }
204
+ kids.push(el('span', { class: 'recg idle', role: 'group', 'aria-label': 'New recording' }, mic, arrow));
205
+ const panel = recording && dock.highlightPanel ? dock.highlightPanel() : null;
206
+ if (panel) kids.push(el('div', { class: 'c3rec__pop' }, panel));
207
+ wrap.replaceChildren(...kids);
208
+ tabDot(st.mode !== 'idle');
209
+ }
210
+ if (rec.clock) rec.clock.textContent = chipText(st);
211
+ };
212
+ /* note-dock reads the notes (a meeting the server still holds open is found again) and re-draws on every state change */
213
+ try { dock.mountDock(); } catch { /* the dock is not available on this page */ }
214
+ let unsub = null;
215
+ import('./state.js').then((m) => { unsub = m.subscribe(sync); }).catch(() => {});
216
+ rec.timer = setInterval(sync, 1000);
217
+ rec.off = () => { if (unsub) unsub(); };
218
+ sync();
219
+ live.set(host, rec);
220
+ }
@@ -647,7 +647,7 @@ function checkSilence(ctx, a) {
647
647
  ctx.setState({});
648
648
  }
649
649
  async function startAudio(ctx, n, want) {
650
- const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, rs24: {}, buf24: {}, peak: {}, began: Date.now(), lost: 0, ended: 0 };
650
+ const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, rs: {}, rs24: {}, buf24: {}, peak: {}, began: Date.now(), lost: 0, ended: 0, cap: null, seq: {}, off: {} };
651
651
  const opened = [];
652
652
  // the meeting first, straight from the press: the browser lets a page share a screen only right after a click
653
653
  for (const kind of want.slice().sort((x, y) => (x === 'meeting' ? -1 : y === 'meeting' ? 1 : 0))) {
@@ -662,6 +662,8 @@ async function startAudio(ctx, n, want) {
662
662
  n.recorded_from = r.recorded_from;
663
663
  // the clock counts the meeting time, as the line times do: Listen again carries on from where it stopped
664
664
  a.started = Date.parse(r.clock_from || r.recorded_from) || a.started;
665
+ // each chunk names its capture, its sequence number and its sample offset, so the server writes it once and in its place
666
+ a.cap = r.capture && r.capture.token ? r.capture.token : null;
665
667
  let ac;
666
668
  // the device's own rate: the 16 kHz audio for transcription and the 24 kHz audio for the replay are both made from the same blocks (D92 as amended)
667
669
  try { ac = new AudioContext(); } catch { ac = new AudioContext({ sampleRate: RATE }); }
@@ -694,13 +696,16 @@ async function startAudio(ctx, n, want) {
694
696
  function sendChunk(ctx, a, kind, final) {
695
697
  const b = a.buf[kind] || []; a.buf[kind] = [];
696
698
  const pcm = b64(Int16Array.from(b));
699
+ const seq = a.seq[kind] = a.seq[kind] === undefined ? 0 : a.seq[kind] + 1;
700
+ const off = a.off[kind] || 0; a.off[kind] = off + b.length;
701
+ const where = a.cap ? { cap: a.cap, seq, off } : {};
697
702
  const b24 = a.buf24[kind] || []; a.buf24[kind] = [];
698
703
  const pcm24 = b24.length ? b64(Int16Array.from(b24)) : undefined;
699
704
  const seconds = b.length / RATE;
700
705
  a.queue[kind] = a.queue[kind].then(async () => {
701
706
  let r = null;
702
707
  for (let tries = 0; tries < SEND_TRIES; tries++) {
703
- r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, pcm24, final });
708
+ r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, pcm24, final, ...where });
704
709
  if (r && r.ok) return;
705
710
  if (r && (r.status === 409 || r.status === 404)) break; // the server refused this note's audio: sending again changes nothing
706
711
  if (tries + 1 < SEND_TRIES) await new Promise((res) => setTimeout(res, SEND_BACKOFF_MS * 3 ** tries));
@@ -119,6 +119,7 @@
119
119
  "pm-live-notes-test.js",
120
120
  "pm-notes-menu-test.js",
121
121
  "pm-record-control-test.js",
122
+ "pm-c3-record-test.js",
122
123
  "pm-r30-test.js",
123
124
  "pm-breakdown-test.js",
124
125
  "pm-breakdown-phone-test.js",
@@ -234,6 +235,7 @@
234
235
  "pm-live-notes-test.js": 24,
235
236
  "pm-notes-menu-test.js": 10,
236
237
  "pm-record-control-test.js": 12,
238
+ "pm-c3-record-test.js": 12,
237
239
  "pm-r30-test.js": 4,
238
240
  "ui-test.js": 30,
239
241
  "plan-items.test.js": 1,
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), the echo canceller for the replay (notes-echo.test.js, notes-audio-quality Run 2) and the optional cleaned copy of the replay (notes-polish.test.js, Run 3) and the 24 kHz replay (notes-hires.test.js, Run 4), plus the note pop-up patched in place while it listens (pm-note-dock-test.js).",
2
+ "_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), the echo canceller for the replay (notes-echo.test.js, notes-audio-quality Run 2) and the optional cleaned copy of the replay (notes-polish.test.js, Run 3) and the 24 kHz replay (notes-hires.test.js, Run 4), plus the note pop-up patched in place while it listens (pm-note-dock-test.js). notes-recording-reliability adds the duplication fixture (notes-duplication.test.js): duplicated audio and transcript across retry, restart, two tabs and a late chunk, and the live guards.",
3
3
  "modules": [
4
4
  "notes.test.js",
5
5
  "notes-listen.test.js",
@@ -7,7 +7,8 @@
7
7
  "notes-qwen.test.js",
8
8
  "notes-echo.test.js",
9
9
  "notes-polish.test.js",
10
- "notes-hires.test.js"
10
+ "notes-hires.test.js",
11
+ "notes-duplication.test.js"
11
12
  ],
12
13
  "panels": [
13
14
  "pm-notes-test.js",
@@ -22,6 +23,7 @@
22
23
  "pm-note-dock-test.js": 20,
23
24
  "notes-echo.test.js": 25,
24
25
  "notes-polish.test.js": 3,
25
- "notes-hires.test.js": 10
26
+ "notes-hires.test.js": 10,
27
+ "notes-duplication.test.js": 25
26
28
  }
27
29
  }