wiki-plugin-audio-note 0.1.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.
package/ReadMe.md ADDED
@@ -0,0 +1,37 @@
1
+ # wiki-plugin-audio-note
2
+
3
+ Federated Wiki plugin: record a voice note in the browser, save it into the wiki's assets folder, and emit a native `audio` item pointing at the saved file.
4
+
5
+ Phase 1 of the FedWiki Media Plan. Client-only — no server component: uploads go through the bundled `wiki-plugin-assets` endpoint (`POST /plugin/assets/upload`), so any wiki that ships `wiki-plugin-assets` (it is a core dependency) can accept recordings from the page owner.
6
+
7
+ ## Features
8
+
9
+ - runtime MIME detection (`audio/webm;codecs=opus` on Chromium, `audio/mp4` on Safari)
10
+ - microphone selector persisted in localStorage, preferring the system default device
11
+ - **Mic Check** monitor mode: calibrated peak dBFS meter before recording — no wasted takes
12
+ - live waveform + level zones while recording (clipping / good / low / no signal)
13
+ - preview playback before saving
14
+ - saves `assets/<page-slug>/<timestamp>--voice-note.<ext>` plus a JSON metadata sidecar (duration, peak dBFS, device label, nullable `participantId`/`consent`/`signature` fields for the later identity layer)
15
+ - emits a native `audio` item with an **absolute URL**, so the note keeps playing when the page is forked
16
+ - quiet-take warning if the recording never peaks above −30 dBFS
17
+ - `window.audioNoteSelfTest()` — permission-free end-to-end test that records a 2 s oscillator tone, saves it, and emits the item (used for automated verification)
18
+
19
+ ## Item format
20
+
21
+ ```json
22
+ { "type": "audio-note", "id": "…", "text": "Caption for the saved audio item" }
23
+ ```
24
+
25
+ After a save the item also carries a `recording` summary (src, filename, mime, duration, peakDb, created).
26
+
27
+ ## Install
28
+
29
+ ```
30
+ ln -s /path/to/wiki-plugin-audio-note <wiki>/node_modules/wiki-plugin-audio-note
31
+ ```
32
+
33
+ and add `"wiki-plugin-audio-note": "*"` to the wiki package's `dependencies` (required for the factory menu and the `/plugins/audio-note/` static route), then restart the wiki.
34
+
35
+ ## License
36
+
37
+ MIT
@@ -0,0 +1,546 @@
1
+ /*
2
+ * Federated Wiki : Audio Note Plugin
3
+ *
4
+ * Records a voice note in the browser, saves the audio file and a JSON
5
+ * metadata sidecar into the wiki assets folder via the bundled
6
+ * wiki-plugin-assets upload endpoint, then emits a native audio item
7
+ * pointing at the saved file.
8
+ *
9
+ * Licensed under the MIT license.
10
+ */
11
+
12
+ const preferredAudioTypes = [
13
+ 'audio/webm;codecs=opus',
14
+ 'audio/webm',
15
+ 'audio/ogg;codecs=opus',
16
+ 'audio/mp4',
17
+ 'audio/wav',
18
+ ]
19
+
20
+ const bestMime = () =>
21
+ preferredAudioTypes.find(t => typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) || ''
22
+
23
+ const randomId = () =>
24
+ [...crypto.getRandomValues(new Uint8Array(8))].map(b => b.toString(16).padStart(2, '0')).join('')
25
+
26
+ const timestampName = (kind, extension) => {
27
+ const stamp = new Date()
28
+ .toISOString()
29
+ .replace(/:/g, '-')
30
+ .replace(/\.\d+Z$/, 'Z')
31
+ return `${stamp}--${kind}.${extension}`
32
+ }
33
+
34
+ const extFor = mime => (mime.match(/audio\/(\w+)/) || [, 'bin'])[1]
35
+
36
+ const MIC_KEY = 'fedwiki-media:micId'
37
+
38
+ // Local Whisper endpoint (FedWiki Media Plan Phase 4, "local-whisper" provider).
39
+ // Override with localStorage key fedwiki-media:transcribeUrl.
40
+ const transcribeUrl = () => localStorage.getItem('fedwiki-media:transcribeUrl') || 'http://localhost:8000/transcribe'
41
+
42
+ let audioContext
43
+ let meterSource // keep a reference — Chrome GCs unreferenced MediaStreamAudioSourceNodes, silencing the meter
44
+
45
+ const levelZone = db => {
46
+ if (db > -3) return ['clipping — back off', '#c0392b']
47
+ if (db > -30) return ['good', '#1a7f37']
48
+ if (db > -50) return ['low — speak up or move closer', '#d68910']
49
+ return ['no signal — wrong microphone?', '#c0392b']
50
+ }
51
+
52
+ const AGC_KEY = 'fedwiki-media:agc'
53
+ const GAIN_KEY = 'fedwiki-media:gainDb'
54
+
55
+ const storedAgc = () => localStorage.getItem(AGC_KEY) !== 'off'
56
+ const storedGainDb = () => +(localStorage.getItem(GAIN_KEY) || 0)
57
+
58
+ const micConstraints = () => {
59
+ const audio = { autoGainControl: storedAgc(), echoCancellation: true, noiseSuppression: true }
60
+ const micId = localStorage.getItem(MIC_KEY)
61
+ if (micId) audio.deviceId = { exact: micId }
62
+ return { audio }
63
+ }
64
+
65
+ async function fillMicSelect(select) {
66
+ const devices = await navigator.mediaDevices.enumerateDevices()
67
+ const current = select.value || localStorage.getItem(MIC_KEY)
68
+ select.innerHTML = ''
69
+ devices
70
+ .filter(d => d.kind === 'audioinput')
71
+ .forEach((d, i) => {
72
+ const opt = document.createElement('option')
73
+ opt.value = d.deviceId
74
+ opt.textContent = d.label || `Microphone ${i + 1}`
75
+ select.appendChild(opt)
76
+ })
77
+ const options = [...select.options]
78
+ if (current && options.some(o => o.value === current)) {
79
+ select.value = current
80
+ } else if (options.some(o => o.value === 'default')) {
81
+ select.value = 'default' // prefer the system default device, not the first enumerated one
82
+ }
83
+ }
84
+
85
+ // ---------- recording with level meter ----------
86
+
87
+ function recordStream(stream, ui, { maxSeconds = 600, label, gainDb = 0 }) {
88
+ return new Promise((resolve, reject) => {
89
+ const mime = bestMime()
90
+ if (!mime) return reject(new Error('No supported MediaRecorder audio type'))
91
+
92
+ const chunks = []
93
+ const startedAt = performance.now()
94
+
95
+ // device info from the original stream, before any gain wrapping
96
+ const track = stream.getAudioTracks()[0]
97
+ const settings = track ? track.getSettings() : {}
98
+ const device = track
99
+ ? { label: track.label, sampleRate: settings.sampleRate, autoGainControl: settings.autoGainControl }
100
+ : null
101
+
102
+ audioContext ||= new AudioContext()
103
+ const analyser = audioContext.createAnalyser()
104
+
105
+ // Software gain: route through a GainNode and record the processed stream, so the
106
+ // gain is baked into the file. The meter taps post-gain — what you see is what saves.
107
+ // (Best fix for quiet input is still the OS input level in System Settings > Sound.)
108
+ let recStream = stream
109
+ if (gainDb !== 0) {
110
+ const source = audioContext.createMediaStreamSource(stream)
111
+ const gainNode = audioContext.createGain()
112
+ gainNode.gain.value = Math.pow(10, gainDb / 20)
113
+ const dest = audioContext.createMediaStreamDestination()
114
+ source.connect(gainNode)
115
+ gainNode.connect(dest)
116
+ gainNode.connect(analyser)
117
+ meterSource = { source, gainNode, dest } // hold refs — Chrome GCs unreferenced audio nodes
118
+ recStream = dest.stream
119
+ } else {
120
+ meterSource = audioContext.createMediaStreamSource(stream)
121
+ meterSource.connect(analyser)
122
+ }
123
+
124
+ const recorder = new MediaRecorder(recStream, { mimeType: mime })
125
+
126
+ const ctx = ui.meter.getContext('2d')
127
+ const wave = new Uint8Array(analyser.frequencyBinCount)
128
+ const buf = new Float32Array(analyser.fftSize)
129
+ let maxPeakDb = -Infinity
130
+ let raf
131
+
132
+ const drawFrame = () => {
133
+ analyser.getByteTimeDomainData(wave)
134
+ ctx.fillStyle = '#111'
135
+ ctx.fillRect(0, 0, ui.meter.width, ui.meter.height)
136
+ ctx.strokeStyle = '#2ecc71'
137
+ ctx.lineWidth = 2
138
+ ctx.beginPath()
139
+ const step = ui.meter.width / wave.length
140
+ wave.forEach((v, i) => {
141
+ const y = (v / 255) * ui.meter.height
142
+ i === 0 ? ctx.moveTo(0, y) : ctx.lineTo(i * step, y)
143
+ })
144
+ ctx.stroke()
145
+
146
+ analyser.getFloatTimeDomainData(buf)
147
+ let peak = 0
148
+ for (const s of buf) {
149
+ const a = Math.abs(s)
150
+ if (a > peak) peak = a
151
+ }
152
+ const db = 20 * Math.log10(peak || 1e-7)
153
+ if (db > maxPeakDb) maxPeakDb = db
154
+ ui.db.textContent = db <= -90 ? '-inf' : db.toFixed(1)
155
+ const [text, color] = levelZone(db)
156
+ ui.zone.textContent = text
157
+ ui.zone.style.color = color
158
+
159
+ raf = requestAnimationFrame(drawFrame)
160
+ }
161
+ drawFrame()
162
+
163
+ const tick = setInterval(() => {
164
+ ui.timer.textContent = ((performance.now() - startedAt) / 1000).toFixed(1) + 's'
165
+ }, 100)
166
+
167
+ const timeout = setTimeout(() => recorder.state !== 'inactive' && recorder.stop(), maxSeconds * 1000)
168
+
169
+ ui.stopFn = () => recorder.state !== 'inactive' && recorder.stop()
170
+
171
+ recorder.ondataavailable = e => e.data.size && chunks.push(e.data)
172
+ recorder.onerror = e => reject(e.error || new Error('MediaRecorder error'))
173
+ recorder.onstop = () => {
174
+ clearTimeout(timeout)
175
+ clearInterval(tick)
176
+ cancelAnimationFrame(raf)
177
+ ctx.fillStyle = '#111'
178
+ ctx.fillRect(0, 0, ui.meter.width, ui.meter.height)
179
+ ui.timer.textContent = ''
180
+ stream.getTracks().forEach(t => t.stop())
181
+ const blob = new Blob(chunks, { type: mime })
182
+ const duration = +((performance.now() - startedAt) / 1000).toFixed(2)
183
+ resolve({ blob, mime, duration, peakDb: +maxPeakDb.toFixed(1), device, label, gainDb })
184
+ }
185
+
186
+ recorder.start()
187
+ })
188
+ }
189
+
190
+ // ---------- save to assets + emit native audio item ----------
191
+
192
+ async function saveRecording($item, item, take) {
193
+ const $page = $item.parents('.page:first')
194
+ const slug = $page.attr('id')
195
+ const filename = timestampName('voice-note', extFor(take.mime))
196
+ const sidecarName = filename.replace(/\.\w+$/, '.json')
197
+ const src = `assets/${slug}/${filename}`
198
+
199
+ const sidecar = {
200
+ type: 'audio-note',
201
+ page: slug,
202
+ itemId: item.id,
203
+ filename,
204
+ src,
205
+ mime: take.mime,
206
+ created: new Date().toISOString(),
207
+ duration: take.duration,
208
+ peakDb: take.peakDb,
209
+ gainDb: take.gainDb,
210
+ device: take.device,
211
+ source: take.label,
212
+ participantId: null,
213
+ consent: null,
214
+ signature: null,
215
+ transcript: null,
216
+ segments: [],
217
+ markers: [],
218
+ }
219
+
220
+ const form = new FormData()
221
+ form.append('assets', slug) // field order matters: multer only sees body fields that precede the files
222
+ form.append('file', new File([take.blob], filename, { type: take.mime }))
223
+ form.append('file', new File([new Blob([JSON.stringify(sidecar, null, 2)], { type: 'application/json' })], sidecarName))
224
+
225
+ const res = await fetch('/plugin/assets/upload', { method: 'POST', body: form })
226
+ if (!res.ok) throw new Error(`upload failed: ${res.status} ${(await res.text()).slice(0, 200)}`)
227
+
228
+ return { src, filename, sidecar }
229
+ }
230
+
231
+ // The wiki server applies each action with an unserialised read-modify-write of the
232
+ // page file, so two simultaneous puts race and the second clobbers the first. Sequence
233
+ // them: pageHandler.put goes through jQuery ajax, so ajaxComplete signals the landing.
234
+ function putAndWait($page, action) {
235
+ return new Promise(resolve => {
236
+ const fallback = setTimeout(() => {
237
+ $(document).off('ajaxComplete', handler)
238
+ resolve()
239
+ }, 2000)
240
+ const handler = (e, xhr, settings) => {
241
+ if ((settings.url || '').includes('/action')) {
242
+ clearTimeout(fallback)
243
+ $(document).off('ajaxComplete', handler)
244
+ resolve()
245
+ }
246
+ }
247
+ $(document).on('ajaxComplete', handler)
248
+ wiki.pageHandler.put($page, action)
249
+ })
250
+ }
251
+
252
+ function emitAudioItem($item, item, src, caption) {
253
+ const audioItem = {
254
+ type: 'audio',
255
+ id: randomId(),
256
+ // absolute URL: assets do not travel when a page is forked, so point at this site explicitly
257
+ text: `${location.origin}/${src}\n${caption}`,
258
+ }
259
+ const $page = $item.parents('.page:first')
260
+ const $new = $('<div />', { class: 'item audio', 'data-id': audioItem.id }).data('item', audioItem).data('pageElement', $page)
261
+ $item.after($new)
262
+ wiki.doPlugin($new, audioItem)
263
+ return audioItem
264
+ }
265
+
266
+ async function saveAndEmit($item, item, take, ui) {
267
+ ui.status.textContent = 'saving…'
268
+ const caption = (item.text || '').trim() || `Voice note — ${new Date().toLocaleString()}`
269
+ const saved = await saveRecording($item, item, take)
270
+ const audioItem = emitAudioItem($item, item, saved.src, caption)
271
+
272
+ item.recording = {
273
+ src: saved.src,
274
+ filename: saved.filename,
275
+ mime: take.mime,
276
+ duration: take.duration,
277
+ peakDb: take.peakDb,
278
+ created: saved.sidecar.created,
279
+ audioItemId: audioItem.id,
280
+ }
281
+ const $page = $item.parents('.page:first')
282
+ await putAndWait($page, { item: audioItem, id: audioItem.id, type: 'add', after: item.id })
283
+ await putAndWait($page, { item, id: item.id, type: 'edit' })
284
+ ui.transcribe.disabled = false
285
+
286
+ const quiet = take.peakDb < -30 ? ` — warning: quiet take (peak ${take.peakDb} dBFS)` : ''
287
+ ui.status.textContent = `saved ${saved.filename} (${(take.blob.size / 1024).toFixed(0)} KB, ${take.duration}s)${quiet}`
288
+ return { saved, audioItem }
289
+ }
290
+
291
+ // ---------- transcription (local Whisper provider) ----------
292
+
293
+ async function transcribeRecording($item, item, ui) {
294
+ const rec = item.recording
295
+ if (!rec?.src) throw new Error('no saved recording to transcribe')
296
+ ui.status.textContent = 'transcribing… (first run downloads the model)'
297
+
298
+ const res = await fetch(transcribeUrl(), {
299
+ method: 'POST',
300
+ headers: { 'Content-Type': 'application/json' },
301
+ body: JSON.stringify({ url: `${location.origin}/${rec.src}` }),
302
+ })
303
+ if (!res.ok) throw new Error(`transcribe failed: ${res.status}`)
304
+ const transcript = await res.json()
305
+ if (transcript.error) throw new Error(transcript.error)
306
+
307
+ // save JSON sidecar and plain-text transcript next to the recording
308
+ const $page = $item.parents('.page:first')
309
+ const slug = $page.attr('id')
310
+ const transcriptName = rec.filename.replace(/\.\w+$/, '.transcript.json')
311
+ const transcriptTxtName = rec.filename.replace(/\.\w+$/, '.transcript.txt')
312
+ const form = new FormData()
313
+ form.append('assets', slug)
314
+ form.append(
315
+ 'file',
316
+ new File([new Blob([JSON.stringify(transcript, null, 2)], { type: 'application/json' })], transcriptName),
317
+ )
318
+ form.append(
319
+ 'file',
320
+ new File([new Blob([transcript.text], { type: 'text/plain' })], transcriptTxtName),
321
+ )
322
+ const up = await fetch('/plugin/assets/upload', { method: 'POST', body: form })
323
+ if (!up.ok) throw new Error(`transcript upload failed: ${up.status}`)
324
+
325
+ // add a markdown item with the transcript text, after the audio item if we know it
326
+ const mdItem = {
327
+ type: 'markdown',
328
+ id: randomId(),
329
+ text: `> ${transcript.text}\n\n*Transcript of ${rec.filename} — ${transcript.provider} (${transcript.model}), language ${transcript.language}.*`,
330
+ }
331
+ const afterId = rec.audioItemId || item.id
332
+ let $anchor = $page.find(`.item[data-id="${afterId}"]`)
333
+ if (!$anchor.length) $anchor = $item
334
+ const $new = $('<div />', { class: 'item markdown', 'data-id': mdItem.id }).data('item', mdItem).data('pageElement', $page)
335
+ $anchor.after($new)
336
+ wiki.doPlugin($new, mdItem)
337
+ await putAndWait($page, { item: mdItem, id: mdItem.id, type: 'add', after: $anchor.length && $anchor[0] !== $item[0] ? afterId : item.id })
338
+
339
+ // update the audio item's caption to [[Page Title]] - [txt-url filename]
340
+ if (rec.audioItemId) {
341
+ const pageTitle = $page.data('data')?.title || slug
342
+ const txtUrl = `${location.origin}/assets/${slug}/${transcriptTxtName}`
343
+ const newCaption = `[[${pageTitle}]] - [${txtUrl} ${transcriptTxtName}]`
344
+ const $audioEl = $page.find(`.item[data-id="${rec.audioItemId}"]`)
345
+ if ($audioEl.length) {
346
+ const audioItem = $audioEl.data('item')
347
+ if (audioItem?.text) {
348
+ const lines = audioItem.text.split('\n')
349
+ lines[1] = newCaption
350
+ audioItem.text = lines.join('\n')
351
+ $audioEl.data('item', audioItem)
352
+ await putAndWait($page, { item: audioItem, id: audioItem.id, type: 'edit' })
353
+ }
354
+ }
355
+ }
356
+
357
+ item.recording.transcript = `assets/${slug}/${transcriptName}`
358
+ item.recording.transcriptTxt = `assets/${slug}/${transcriptTxtName}`
359
+ await putAndWait($page, { item, id: item.id, type: 'edit' })
360
+
361
+ ui.status.textContent = `transcribed — ${transcript.segments.length} segments, language ${transcript.language}`
362
+ return transcript
363
+ }
364
+
365
+ // ---------- item UI ----------
366
+
367
+ const html = item => `
368
+ <div style="background-color:#eee; padding:12px; border-radius:6px;">
369
+ <select class="an-mic" style="width:100%; font-size:.85em; margin-bottom:6px;"></select>
370
+ <div>
371
+ <button class="an-check">Mic Check</button>
372
+ <button class="an-record">Record</button>
373
+ <button class="an-stop" disabled>Stop</button>
374
+ <button class="an-save" disabled>Save to Assets</button>
375
+ <button class="an-transcribe"${item.recording?.src ? '' : ' disabled'}>Transcribe</button>
376
+ </div>
377
+ <div style="font-size:.8em; color:#444; margin:4px 0;">
378
+ <label><input type="checkbox" class="an-agc"> auto&nbsp;gain</label>
379
+ &nbsp; boost <input type="range" class="an-gain" min="0" max="24" step="1" value="0"
380
+ style="width:110px; vertical-align:middle;"> +<span class="an-gaindb">0</span>&nbsp;dB
381
+ </div>
382
+ <div style="font-family:monospace; font-size:.85em; margin:4px 0;">
383
+ peak <span class="an-db">–</span> dBFS <span class="an-zone"></span> <span class="an-timer"></span>
384
+ </div>
385
+ <canvas class="an-meter" width="400" height="48" style="width:100%; background:#111; border-radius:4px;"></canvas>
386
+ <audio class="an-player" controls style="width:100%; margin-top:6px; display:none;"></audio>
387
+ <div class="an-status" style="font-size:.8em; color:#555; margin-top:4px;">${
388
+ item.recording ? `last saved: ${item.recording.filename} (${item.recording.duration}s)` : 'no recording yet'
389
+ }</div>
390
+ <p class="an-caption" style="font-style:italic; margin:6px 0 0;">${wiki.resolveLinks(item.text || '(double-click to add a caption)')}</p>
391
+ </div>
392
+ `
393
+
394
+ const getUI = $item => {
395
+ const el = sel => $item.find(sel)[0]
396
+ return {
397
+ mic: el('.an-mic'),
398
+ agc: el('.an-agc'),
399
+ gain: el('.an-gain'),
400
+ gaindb: el('.an-gaindb'),
401
+ check: el('.an-check'),
402
+ record: el('.an-record'),
403
+ stop: el('.an-stop'),
404
+ save: el('.an-save'),
405
+ transcribe: el('.an-transcribe'),
406
+ db: el('.an-db'),
407
+ zone: el('.an-zone'),
408
+ timer: el('.an-timer'),
409
+ meter: el('.an-meter'),
410
+ player: el('.an-player'),
411
+ status: el('.an-status'),
412
+ stopFn: null,
413
+ }
414
+ }
415
+
416
+ const emit = ($item, item) => {
417
+ $item.append(html(item))
418
+ }
419
+
420
+ const bind = ($item, item) => {
421
+ const ui = getUI($item)
422
+ let take = null
423
+ let checkStream = null
424
+
425
+ fillMicSelect(ui.mic).catch(() => {})
426
+ ui.mic.onchange = () => localStorage.setItem(MIC_KEY, ui.mic.value)
427
+
428
+ ui.agc.checked = storedAgc()
429
+ ui.agc.onchange = () => localStorage.setItem(AGC_KEY, ui.agc.checked ? 'on' : 'off')
430
+ ui.gain.value = storedGainDb()
431
+ ui.gaindb.textContent = ui.gain.value
432
+ ui.gain.oninput = () => {
433
+ ui.gaindb.textContent = ui.gain.value
434
+ localStorage.setItem(GAIN_KEY, ui.gain.value)
435
+ }
436
+ const currentGainDb = () => +ui.gain.value
437
+
438
+ const setBusy = busy => {
439
+ ui.record.disabled = busy
440
+ ui.check.disabled = busy && !checkStream
441
+ ui.stop.disabled = !busy
442
+ ui.save.disabled = busy || !take
443
+ ui.record.style.background = busy ? '#c0392b' : ''
444
+ ui.record.style.color = busy ? 'white' : ''
445
+ }
446
+
447
+ const stopCheck = () => {
448
+ if (!checkStream) return
449
+ checkStream.monitor.stopFn?.()
450
+ checkStream = null
451
+ ui.check.textContent = 'Mic Check'
452
+ }
453
+
454
+ ui.check.onclick = async () => {
455
+ if (checkStream) return stopCheck()
456
+ try {
457
+ const stream = await navigator.mediaDevices.getUserMedia(micConstraints())
458
+ await fillMicSelect(ui.mic) // labels unlock after first permission
459
+ ui.check.textContent = 'Stop Check'
460
+ const monitor = { meter: ui.meter, db: ui.db, zone: ui.zone, timer: ui.timer, stopFn: null }
461
+ checkStream = { stream, monitor }
462
+ // monitor without keeping the recording
463
+ recordStream(stream, monitor, { maxSeconds: 3600, label: 'mic-check', gainDb: currentGainDb() }).catch(() => {})
464
+ } catch (err) {
465
+ ui.status.textContent = 'error: ' + err.message
466
+ }
467
+ }
468
+
469
+ ui.record.onclick = async () => {
470
+ try {
471
+ stopCheck()
472
+ const stream = await navigator.mediaDevices.getUserMedia(micConstraints())
473
+ await fillMicSelect(ui.mic)
474
+ setBusy(true)
475
+ take = await recordStream(stream, ui, { maxSeconds: 600, label: 'microphone', gainDb: currentGainDb() })
476
+ ui.player.src = URL.createObjectURL(take.blob)
477
+ ui.player.style.display = ''
478
+ const quiet = take.peakDb < -30 ? ` — quiet take (peak ${take.peakDb} dBFS), check mic before saving` : ''
479
+ ui.status.textContent = `recorded ${take.duration}s, peak ${take.peakDb} dBFS${quiet}`
480
+ } catch (err) {
481
+ ui.status.textContent = 'error: ' + err.message
482
+ } finally {
483
+ setBusy(false)
484
+ }
485
+ }
486
+
487
+ ui.stop.onclick = () => ui.stopFn?.()
488
+
489
+ ui.transcribe.onclick = async () => {
490
+ try {
491
+ ui.transcribe.disabled = true
492
+ await transcribeRecording($item, item, ui)
493
+ } catch (err) {
494
+ ui.status.textContent = 'error: ' + err.message
495
+ } finally {
496
+ ui.transcribe.disabled = !item.recording?.src
497
+ }
498
+ }
499
+
500
+ ui.save.onclick = async () => {
501
+ if (!take) return
502
+ try {
503
+ ui.save.disabled = true
504
+ await saveAndEmit($item, item, take, ui)
505
+ take = null
506
+ } catch (err) {
507
+ ui.status.textContent = 'error: ' + err.message
508
+ ui.save.disabled = false
509
+ }
510
+ }
511
+
512
+ $item.on('dblclick', e => {
513
+ if ($(e.target).closest('button, select, audio, canvas').length) return
514
+ wiki.textEditor($item, item)
515
+ })
516
+
517
+ // Permission-free end-to-end test: record a 2s oscillator tone, save it, emit the audio item.
518
+ window.audioNoteSelfTest ||= async (seconds = 2) => {
519
+ audioContext ||= new AudioContext()
520
+ await audioContext.resume()
521
+ if (audioContext.state !== 'running') {
522
+ throw new Error('AudioContext is ' + audioContext.state + ' — autoplay policy needs a user gesture first')
523
+ }
524
+ const dest = audioContext.createMediaStreamDestination()
525
+ const osc = audioContext.createOscillator()
526
+ const gain = audioContext.createGain()
527
+ gain.gain.value = 0.5 // -6 dBFS
528
+ osc.connect(gain).connect(dest)
529
+ osc.start()
530
+ setBusy(true)
531
+ try {
532
+ const testTake = await recordStream(dest.stream, ui, { maxSeconds: seconds, label: 'self-test-oscillator', gainDb: currentGainDb() })
533
+ osc.stop()
534
+ const { saved, audioItem } = await saveAndEmit($item, item, testTake, ui)
535
+ return { take: { mime: testTake.mime, bytes: testTake.blob.size, duration: testTake.duration, peakDb: testTake.peakDb }, saved: saved.src, audioItem }
536
+ } finally {
537
+ setBusy(false)
538
+ }
539
+ }
540
+ }
541
+
542
+ if (typeof window !== 'undefined') {
543
+ window.plugins['audio-note'] = { emit, bind }
544
+ }
545
+
546
+ export const audioNote = typeof window == 'undefined' ? { preferredAudioTypes, timestampName, extFor, levelZone } : undefined
package/factory.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "Audio-Note",
3
+ "title": "Audio Note — record a voice note into the page",
4
+ "category": "format"
5
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "wiki-plugin-audio-note",
3
+ "version": "0.1.0",
4
+ "description": "Federated Wiki - Audio Note Plugin: record a voice note in the browser, save it into wiki assets, emit a native audio item",
5
+ "keywords": [
6
+ "audio",
7
+ "recorder",
8
+ "voice note",
9
+ "wiki",
10
+ "federated wiki",
11
+ "plugin"
12
+ ],
13
+ "author": {
14
+ "name": "David Bovill"
15
+ },
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/Hitchhikers-Guide-to-the-Galaxy/wiki-plugin-audio-note.git"
20
+ }
21
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "title": "About Audio-Note Plugin",
3
+ "story": [
4
+ {
5
+ "type": "markdown",
6
+ "id": "a4d10e6b22c91f01",
7
+ "text": "The Audio Note plugin records a voice note in the browser and saves it into the wiki page's assets folder, then adds a native audio item to the page so the recording plays everywhere — even on sites without this plugin installed."
8
+ },
9
+ {
10
+ "type": "audio-note",
11
+ "id": "a4d10e6b22c91f02",
12
+ "text": "Try recording a note here."
13
+ },
14
+ {
15
+ "type": "markdown",
16
+ "id": "a4d10e6b22c91f03",
17
+ "text": "## How it works"
18
+ },
19
+ {
20
+ "type": "markdown",
21
+ "id": "a4d10e6b22c91f04",
22
+ "text": "Pick a microphone, click *Mic Check* to confirm the level on the dBFS meter, then *Record* (up to 10 minutes), preview, and *Save to Assets*. The audio file and a JSON metadata sidecar (duration, peak level, device, timestamps) are uploaded through the bundled assets endpoint into `assets/<page-slug>/`, and a native audio item appears below with an absolute URL so it survives forking. A take that never peaks above −30 dBFS gets a quiet-take warning before you save. Double-click the caption area to edit the caption used for the audio item."
23
+ }
24
+ ],
25
+ "journal": [
26
+ {
27
+ "type": "create",
28
+ "item": {
29
+ "title": "About Audio-Note Plugin",
30
+ "story": [
31
+ {
32
+ "type": "markdown",
33
+ "id": "a4d10e6b22c91f01",
34
+ "text": "The Audio Note plugin records a voice note in the browser and saves it into the wiki page's assets folder, then adds a native audio item to the page so the recording plays everywhere — even on sites without this plugin installed."
35
+ },
36
+ {
37
+ "type": "audio-note",
38
+ "id": "a4d10e6b22c91f02",
39
+ "text": "Try recording a note here."
40
+ },
41
+ {
42
+ "type": "markdown",
43
+ "id": "a4d10e6b22c91f03",
44
+ "text": "## How it works"
45
+ },
46
+ {
47
+ "type": "markdown",
48
+ "id": "a4d10e6b22c91f04",
49
+ "text": "Pick a microphone, click *Mic Check* to confirm the level on the dBFS meter, then *Record* (up to 10 minutes), preview, and *Save to Assets*. The audio file and a JSON metadata sidecar (duration, peak level, device, timestamps) are uploaded through the bundled assets endpoint into `assets/<page-slug>/`, and a native audio item appears below with an absolute URL so it survives forking. A take that never peaks above −30 dBFS gets a quiet-take warning before you save. Double-click the caption area to edit the caption used for the audio item."
50
+ }
51
+ ]
52
+ },
53
+ "date": 1781286000000,
54
+ "certificate": "from marvin"
55
+ }
56
+ ],
57
+ "plugin": "audio-note"
58
+ }