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 +37 -0
- package/client/audio-note.js +546 -0
- package/factory.json +5 -0
- package/package.json +21 -0
- package/pages/about-audio-note-plugin +58 -0
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 gain</label>
|
|
379
|
+
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> 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
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
|
+
}
|