@joewinke/jatui 0.21.0 → 0.23.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/CLAUDE.md +47 -1
- package/dist/actions/reveal.d.ts +37 -0
- package/dist/actions/reveal.js +42 -0
- package/dist/components/AudioRecorder.svelte +749 -0
- package/dist/components/AudioRecorder.svelte.d.ts +36 -0
- package/dist/components/IconSelector.svelte +417 -0
- package/dist/components/IconSelector.svelte.d.ts +55 -0
- package/dist/components/InfiniteGrid.svelte +305 -0
- package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
- package/dist/components/InfiniteSearchGrid.svelte +131 -0
- package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
- package/dist/components/KeyboardShortcutsOverlay.svelte +40 -3
- package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +31 -16
- package/dist/components/MediaCaptureIcon.svelte +23 -0
- package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
- package/dist/components/ThumbnailGrid.svelte +137 -0
- package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
- package/dist/components/batch/BatchJobTable.svelte +549 -0
- package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
- package/dist/components/batch/QuotaMeter.svelte +78 -0
- package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
- package/dist/components/batch/batchQueue.d.ts +56 -0
- package/dist/components/batch/batchQueue.js +81 -0
- package/dist/components/batch/elapsedEstimate.d.ts +24 -0
- package/dist/components/batch/elapsedEstimate.js +25 -0
- package/dist/components/batch/pollOperation.d.ts +45 -0
- package/dist/components/batch/pollOperation.js +80 -0
- package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
- package/dist/components/batch/useElapsedEstimate.js +38 -0
- package/dist/components/infiniteGridLoader.d.ts +70 -0
- package/dist/components/infiniteGridLoader.js +141 -0
- package/dist/components/table/columnResize.d.ts +38 -0
- package/dist/components/table/columnResize.js +117 -0
- package/dist/components/table/index.d.ts +2 -0
- package/dist/components/table/index.js +1 -0
- package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
- package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
- package/dist/components/variant/variantCombinations.d.ts +75 -0
- package/dist/components/variant/variantCombinations.js +100 -0
- package/dist/history.d.ts +44 -0
- package/dist/history.js +87 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +27 -0
- package/dist/styles/motion.css +100 -0
- package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
- package/dist/utils/keyboardShortcutRegistry.js +365 -0
- package/package.json +5 -1
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
/** What a finished recording hands back to the host — mirrors MediaCaptureResult's shape for the audio kind. */
|
|
3
|
+
export interface AudioRecordingResult {
|
|
4
|
+
blob: Blob
|
|
5
|
+
mimeType: string
|
|
6
|
+
durationSeconds: number
|
|
7
|
+
sizeBytes: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** m:ss zero-padded seconds — shared format for the timer display. */
|
|
11
|
+
export function formatDuration(totalSeconds: number): string {
|
|
12
|
+
const mins = Math.floor(totalSeconds / 60)
|
|
13
|
+
const secs = Math.floor(totalSeconds % 60)
|
|
14
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 1.2 KB / 3.4 MB style byte formatting for the size readout. */
|
|
18
|
+
export function formatFileSize(bytes: number): string {
|
|
19
|
+
if (bytes <= 0) return '0 B'
|
|
20
|
+
const k = 1024
|
|
21
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
22
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
|
|
23
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<script lang="ts">
|
|
28
|
+
/**
|
|
29
|
+
* AudioRecorder — voice-memo recorder with live waveform, ported from
|
|
30
|
+
* chimaro's chat/AudioRecorder.svelte (jatui-m4uz3.5).
|
|
31
|
+
*
|
|
32
|
+
* Completes jatui's audio trio alongside AudioWaveform (playback) and
|
|
33
|
+
* SpeechForm (voice-to-text input): this one is the CAPTURE surface.
|
|
34
|
+
*
|
|
35
|
+
* Ported as a host-agnostic primitive — everything chimaro-specific is
|
|
36
|
+
* decoupled into emitted callbacks:
|
|
37
|
+
* • no promptStore — the custom-prompt textarea was host state, not
|
|
38
|
+
* recorder state, and lives with the host;
|
|
39
|
+
* • no debugConfig — console chatter replaced by callback props;
|
|
40
|
+
* • no supabase template loading / analysis panel — hosts that want
|
|
41
|
+
* post-recording transcription or analysis consume `onRecordStop`'s
|
|
42
|
+
* blob payload and run their own pipeline (MardukOS Scribe does).
|
|
43
|
+
*
|
|
44
|
+
* What it owns: permission requests (with an explicit denied state),
|
|
45
|
+
* MIME negotiation, record/pause/resume/stop/cancel state machine, the
|
|
46
|
+
* theme-aware canvas waveform + level meter, and size/duration readouts.
|
|
47
|
+
*
|
|
48
|
+
* State machine: idle → recording ⇄ paused → processing → done, with
|
|
49
|
+
* cancel from any recording phase returning straight to idle. `reset()`
|
|
50
|
+
* returns a finished recorder to idle so the host can record again.
|
|
51
|
+
*
|
|
52
|
+
* Timer discipline (lesson from MediaCapture, mos-ow4q4.37): elapsed time
|
|
53
|
+
* is derived from the wall clock minus accumulated pause, never an
|
|
54
|
+
* incremented counter — a throttled background tab can't freeze it.
|
|
55
|
+
*/
|
|
56
|
+
import { onDestroy, onMount } from 'svelte'
|
|
57
|
+
import { getDaisyUIColor, watchThemeChanges } from '../utils/daisyuiColors'
|
|
58
|
+
import MediaCaptureIcon from './MediaCaptureIcon.svelte'
|
|
59
|
+
|
|
60
|
+
interface Props {
|
|
61
|
+
/** `small` — inline chat-style pill (default). `large` — full recorder panel with waveform canvas. */
|
|
62
|
+
variant?: 'small' | 'large'
|
|
63
|
+
/** Auto-stop ceiling in ms. 0 (default) records until stopped manually. */
|
|
64
|
+
maxDurationMs?: number
|
|
65
|
+
disabled?: boolean
|
|
66
|
+
/** Fired once the mic stream is live and recording has started. */
|
|
67
|
+
onRecordStart?: () => void
|
|
68
|
+
onRecordPause?: () => void
|
|
69
|
+
onRecordResume?: () => void
|
|
70
|
+
/** Fired with the finished recording. May return a Promise — the
|
|
71
|
+
* component stays in "processing" until it settles, so hosts can
|
|
72
|
+
* upload/transcribe without the user re-triggering a recording. */
|
|
73
|
+
onRecordStop?: (result: AudioRecordingResult) => void | Promise<void>
|
|
74
|
+
/** Fired when the user cancels — no blob is produced. */
|
|
75
|
+
onRecordCancel?: () => void
|
|
76
|
+
/** Fired on recorder errors (permission denied, no mic, start failure). */
|
|
77
|
+
onError?: (message: string) => void
|
|
78
|
+
class?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let {
|
|
82
|
+
variant = 'small',
|
|
83
|
+
maxDurationMs = 0,
|
|
84
|
+
disabled = false,
|
|
85
|
+
onRecordStart,
|
|
86
|
+
onRecordPause,
|
|
87
|
+
onRecordResume,
|
|
88
|
+
onRecordStop,
|
|
89
|
+
onRecordCancel,
|
|
90
|
+
onError,
|
|
91
|
+
class: className = ''
|
|
92
|
+
}: Props = $props()
|
|
93
|
+
|
|
94
|
+
type Phase = 'idle' | 'recording' | 'processing' | 'done'
|
|
95
|
+
type PauseState = 'active' | 'paused'
|
|
96
|
+
|
|
97
|
+
let phase = $state<Phase>('idle')
|
|
98
|
+
let paused = $state<PauseState>('active')
|
|
99
|
+
let permissionDenied = $state(false)
|
|
100
|
+
let errorMessage = $state('')
|
|
101
|
+
let elapsedSec = $state(0)
|
|
102
|
+
let sizeBytes = $state(0)
|
|
103
|
+
let audioLevel = $state(0)
|
|
104
|
+
|
|
105
|
+
// Recorder internals
|
|
106
|
+
let recorder: MediaRecorder | null = null
|
|
107
|
+
let stream: MediaStream | null = null
|
|
108
|
+
let audioCtx: AudioContext | null = null
|
|
109
|
+
let analyser: AnalyserNode | null = null
|
|
110
|
+
let chunks: Blob[] = []
|
|
111
|
+
let mimeType = ''
|
|
112
|
+
let startedAt = 0
|
|
113
|
+
let pausedTotalMs = 0
|
|
114
|
+
let pauseStartedAt = 0
|
|
115
|
+
let raf: number | null = null
|
|
116
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null
|
|
117
|
+
let autoStopTimer: ReturnType<typeof setTimeout> | null = null
|
|
118
|
+
let wasCancelled = false
|
|
119
|
+
|
|
120
|
+
// Canvas
|
|
121
|
+
let canvas = $state<HTMLCanvasElement | null>(null)
|
|
122
|
+
let canvasCtx: CanvasRenderingContext2D | null = null
|
|
123
|
+
let canvasColors = { background: '', centerLine: '', primary: '', secondary: '', dots: '' }
|
|
124
|
+
|
|
125
|
+
const hasUserMedia =
|
|
126
|
+
typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
|
|
127
|
+
const hasRecorder = typeof MediaRecorder !== 'undefined'
|
|
128
|
+
const supported = hasRecorder && hasUserMedia
|
|
129
|
+
|
|
130
|
+
function resolveColor(name: string, fallback: string): string {
|
|
131
|
+
if (typeof window === 'undefined') return fallback
|
|
132
|
+
try {
|
|
133
|
+
const c = getDaisyUIColor(name)
|
|
134
|
+
return c && c !== 'undefined' && c !== '' ? c : fallback
|
|
135
|
+
} catch {
|
|
136
|
+
return fallback
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function updateCanvasColors() {
|
|
141
|
+
canvasColors = {
|
|
142
|
+
background: resolveColor('base-200', '#1f2937'),
|
|
143
|
+
centerLine: resolveColor('base-300', '#374151'),
|
|
144
|
+
primary: resolveColor('primary', '#3b82f6'),
|
|
145
|
+
secondary: resolveColor('success', '#10b981'),
|
|
146
|
+
dots: resolveColor('base-content', '#6b7280')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pickMime(): string {
|
|
151
|
+
for (const c of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']) {
|
|
152
|
+
if (MediaRecorder.isTypeSupported(c)) return c
|
|
153
|
+
}
|
|
154
|
+
return ''
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Elapsed from the wall clock, pause-adjusted — see header note. */
|
|
158
|
+
function elapsedMs(): number {
|
|
159
|
+
if (!startedAt) return 0
|
|
160
|
+
const pausedMs = pausedTotalMs + (pauseStartedAt ? Date.now() - pauseStartedAt : 0)
|
|
161
|
+
return Math.max(0, Date.now() - startedAt - pausedMs)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function tick() {
|
|
165
|
+
elapsedSec = Math.floor(elapsedMs() / 1000)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function stopAllTracks() {
|
|
169
|
+
stream?.getTracks().forEach((t) => t.stop())
|
|
170
|
+
stream = null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stopAnimation() {
|
|
174
|
+
if (raf !== null) {
|
|
175
|
+
cancelAnimationFrame(raf)
|
|
176
|
+
raf = null
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function clearTimers() {
|
|
181
|
+
if (tickTimer) {
|
|
182
|
+
clearInterval(tickTimer)
|
|
183
|
+
tickTimer = null
|
|
184
|
+
}
|
|
185
|
+
if (autoStopTimer) {
|
|
186
|
+
clearTimeout(autoStopTimer)
|
|
187
|
+
autoStopTimer = null
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Tear down every media resource. Idempotent — safe from cancel/stop/destroy. */
|
|
192
|
+
function cleanup() {
|
|
193
|
+
wasCancelled = true
|
|
194
|
+
if (recorder && recorder.state !== 'inactive') {
|
|
195
|
+
recorder.ondataavailable = null
|
|
196
|
+
recorder.onstop = null
|
|
197
|
+
try {
|
|
198
|
+
recorder.stop()
|
|
199
|
+
} catch {
|
|
200
|
+
// already inactive
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
recorder = null
|
|
204
|
+
stopAllTracks()
|
|
205
|
+
if (audioCtx && audioCtx.state !== 'closed') {
|
|
206
|
+
audioCtx.close().catch(() => {})
|
|
207
|
+
}
|
|
208
|
+
audioCtx = null
|
|
209
|
+
analyser = null
|
|
210
|
+
stopAnimation()
|
|
211
|
+
clearTimers()
|
|
212
|
+
chunks = []
|
|
213
|
+
sizeBytes = 0
|
|
214
|
+
audioLevel = 0
|
|
215
|
+
startedAt = 0
|
|
216
|
+
pausedTotalMs = 0
|
|
217
|
+
pauseStartedAt = 0
|
|
218
|
+
elapsedSec = 0
|
|
219
|
+
paused = 'active'
|
|
220
|
+
clearCanvas()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sizeCanvas() {
|
|
224
|
+
if (!canvas) return
|
|
225
|
+
const parent = canvas.parentElement
|
|
226
|
+
const width = Math.max(160, Math.min(parent?.clientWidth ?? 480, 800))
|
|
227
|
+
const height = 80
|
|
228
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
229
|
+
canvas.width = width
|
|
230
|
+
canvas.height = height
|
|
231
|
+
}
|
|
232
|
+
clearCanvas()
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function clearCanvas() {
|
|
236
|
+
if (!canvas || !canvasCtx) return
|
|
237
|
+
const { width, height } = canvas
|
|
238
|
+
canvasCtx.clearRect(0, 0, width, height)
|
|
239
|
+
canvasCtx.fillStyle = canvasColors.background
|
|
240
|
+
canvasCtx.fillRect(0, 0, width, height)
|
|
241
|
+
canvasCtx.strokeStyle = canvasColors.centerLine
|
|
242
|
+
canvasCtx.lineWidth = 1
|
|
243
|
+
canvasCtx.beginPath()
|
|
244
|
+
canvasCtx.moveTo(0, height / 2)
|
|
245
|
+
canvasCtx.lineTo(width, height / 2)
|
|
246
|
+
canvasCtx.stroke()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function drawIdleDots() {
|
|
250
|
+
if (!canvas || !canvasCtx) return
|
|
251
|
+
const { width, height } = canvas
|
|
252
|
+
const count = Math.floor(width / 20)
|
|
253
|
+
const spacing = width / (count + 1)
|
|
254
|
+
const t = Date.now() * 0.003
|
|
255
|
+
canvasCtx.fillStyle = canvasColors.dots
|
|
256
|
+
for (let i = 0; i < count; i++) {
|
|
257
|
+
const pulse = Math.sin(t + i * 0.5) * 0.3 + 0.7
|
|
258
|
+
canvasCtx.beginPath()
|
|
259
|
+
canvasCtx.arc((i + 1) * spacing, height / 2, 1.5 * pulse, 0, Math.PI * 2)
|
|
260
|
+
canvasCtx.fill()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function drawWaveform(freq: Uint8Array) {
|
|
265
|
+
if (!canvas || !canvasCtx) return
|
|
266
|
+
const { width, height } = canvas
|
|
267
|
+
canvasCtx.fillStyle = canvasColors.background
|
|
268
|
+
canvasCtx.fillRect(0, 0, width, height)
|
|
269
|
+
canvasCtx.strokeStyle = canvasColors.centerLine
|
|
270
|
+
canvasCtx.lineWidth = 1
|
|
271
|
+
canvasCtx.beginPath()
|
|
272
|
+
canvasCtx.moveTo(0, height / 2)
|
|
273
|
+
canvasCtx.lineTo(width, height / 2)
|
|
274
|
+
canvasCtx.stroke()
|
|
275
|
+
|
|
276
|
+
const barCount = 60
|
|
277
|
+
const gap = 1
|
|
278
|
+
const barWidth = Math.max(1, Math.floor((width - (barCount - 1) * gap) / barCount))
|
|
279
|
+
const centerY = height / 2
|
|
280
|
+
const usable = Math.floor(freq.length * 0.75)
|
|
281
|
+
const perBar = Math.max(1, Math.floor(usable / barCount))
|
|
282
|
+
|
|
283
|
+
const gradient = canvasCtx.createLinearGradient(0, 0, 0, height)
|
|
284
|
+
gradient.addColorStop(0, canvasColors.primary)
|
|
285
|
+
gradient.addColorStop(0.5, canvasColors.secondary)
|
|
286
|
+
gradient.addColorStop(1, canvasColors.primary)
|
|
287
|
+
canvasCtx.fillStyle = gradient
|
|
288
|
+
|
|
289
|
+
for (let i = 0; i < barCount; i++) {
|
|
290
|
+
let sum = 0
|
|
291
|
+
for (let j = 0; j < perBar; j++) {
|
|
292
|
+
const idx = i * perBar + j
|
|
293
|
+
if (idx < usable) sum += freq[idx]
|
|
294
|
+
}
|
|
295
|
+
const amplified = (sum / perBar / 255) * 1.5
|
|
296
|
+
const h = Math.max(amplified * (height / 2 - 2), 0.5)
|
|
297
|
+
canvasCtx.fillRect(i * (barWidth + gap), centerY - h, barWidth, h * 2)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function animate() {
|
|
302
|
+
if (!analyser || phase !== 'recording') return
|
|
303
|
+
if (paused === 'paused') {
|
|
304
|
+
if (canvas) {
|
|
305
|
+
sizeCanvasIfNeeded()
|
|
306
|
+
clearCanvas()
|
|
307
|
+
drawIdleDots()
|
|
308
|
+
}
|
|
309
|
+
audioLevel = 0
|
|
310
|
+
raf = requestAnimationFrame(animate)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const freq = new Uint8Array(analyser.frequencyBinCount)
|
|
314
|
+
const time = new Uint8Array(analyser.frequencyBinCount)
|
|
315
|
+
analyser.getByteFrequencyData(freq)
|
|
316
|
+
analyser.getByteTimeDomainData(time)
|
|
317
|
+
let sum = 0
|
|
318
|
+
for (let i = 0; i < time.length; i++) {
|
|
319
|
+
const s = (time[i] - 128) / 128
|
|
320
|
+
sum += s * s
|
|
321
|
+
}
|
|
322
|
+
audioLevel = Math.min(100, Math.sqrt(sum / time.length) * 300)
|
|
323
|
+
if (canvas) {
|
|
324
|
+
sizeCanvasIfNeeded()
|
|
325
|
+
if (freq.some((v) => v > 0)) drawWaveform(freq)
|
|
326
|
+
else {
|
|
327
|
+
clearCanvas()
|
|
328
|
+
drawIdleDots()
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
raf = requestAnimationFrame(animate)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let lastCanvasWidth = 0
|
|
335
|
+
/** Re-measure the canvas when its container resizes, without churning every frame. */
|
|
336
|
+
function sizeCanvasIfNeeded() {
|
|
337
|
+
if (!canvas || !canvas.parentElement) return
|
|
338
|
+
const w = Math.max(160, Math.min(canvas.parentElement.clientWidth, 800))
|
|
339
|
+
if (w !== lastCanvasWidth) {
|
|
340
|
+
lastCanvasWidth = w
|
|
341
|
+
sizeCanvas()
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function start() {
|
|
346
|
+
if (!supported || disabled || phase === 'recording' || phase === 'processing') return
|
|
347
|
+
errorMessage = ''
|
|
348
|
+
permissionDenied = false
|
|
349
|
+
try {
|
|
350
|
+
stream = await navigator.mediaDevices.getUserMedia({
|
|
351
|
+
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
|
352
|
+
})
|
|
353
|
+
mimeType = pickMime()
|
|
354
|
+
recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream)
|
|
355
|
+
mimeType = recorder.mimeType || mimeType || 'audio/webm'
|
|
356
|
+
chunks = []
|
|
357
|
+
sizeBytes = 0
|
|
358
|
+
wasCancelled = false
|
|
359
|
+
|
|
360
|
+
recorder.ondataavailable = (e) => {
|
|
361
|
+
if (e.data.size > 0) {
|
|
362
|
+
chunks.push(e.data)
|
|
363
|
+
sizeBytes += e.data.size
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
recorder.onstop = () => {
|
|
367
|
+
void finalize()
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Waveform analyser — visualization only, never touches the recorded data.
|
|
371
|
+
audioCtx = new AudioContext()
|
|
372
|
+
analyser = audioCtx.createAnalyser()
|
|
373
|
+
analyser.fftSize = 512
|
|
374
|
+
analyser.smoothingTimeConstant = 0.3
|
|
375
|
+
analyser.minDecibels = -100
|
|
376
|
+
analyser.maxDecibels = -20
|
|
377
|
+
audioCtx.createMediaStreamSource(stream).connect(analyser)
|
|
378
|
+
|
|
379
|
+
recorder.start(1000)
|
|
380
|
+
startedAt = Date.now()
|
|
381
|
+
pausedTotalMs = 0
|
|
382
|
+
pauseStartedAt = 0
|
|
383
|
+
elapsedSec = 0
|
|
384
|
+
paused = 'active'
|
|
385
|
+
phase = 'recording'
|
|
386
|
+
tickTimer = setInterval(tick, 250)
|
|
387
|
+
if (maxDurationMs > 0) {
|
|
388
|
+
autoStopTimer = setTimeout(stop, maxDurationMs)
|
|
389
|
+
}
|
|
390
|
+
updateCanvasColors()
|
|
391
|
+
// Canvas mounts with the recording UI — give Svelte a tick, then go live.
|
|
392
|
+
setTimeout(() => {
|
|
393
|
+
if (canvas) {
|
|
394
|
+
canvasCtx = canvas.getContext('2d')
|
|
395
|
+
lastCanvasWidth = 0
|
|
396
|
+
sizeCanvas()
|
|
397
|
+
}
|
|
398
|
+
stopAnimation()
|
|
399
|
+
raf = requestAnimationFrame(animate)
|
|
400
|
+
}, 0)
|
|
401
|
+
onRecordStart?.()
|
|
402
|
+
} catch (err) {
|
|
403
|
+
cleanup()
|
|
404
|
+
phase = 'idle'
|
|
405
|
+
const name = (err as DOMException)?.name ?? ''
|
|
406
|
+
let message: string
|
|
407
|
+
if (name === 'NotAllowedError' || name === 'SecurityError') {
|
|
408
|
+
permissionDenied = true
|
|
409
|
+
message = 'Microphone access was denied. Allow mic permission in your browser settings and try again.'
|
|
410
|
+
} else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
|
411
|
+
message = 'No microphone was found. Connect one and try again.'
|
|
412
|
+
} else {
|
|
413
|
+
message = 'Failed to start recording. Check the microphone and try again.'
|
|
414
|
+
}
|
|
415
|
+
errorMessage = message
|
|
416
|
+
onError?.(message)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function pause() {
|
|
421
|
+
if (phase !== 'recording' || paused === 'paused' || !recorder) return
|
|
422
|
+
try {
|
|
423
|
+
recorder.pause()
|
|
424
|
+
paused = 'paused'
|
|
425
|
+
pauseStartedAt = Date.now()
|
|
426
|
+
stopAnimation()
|
|
427
|
+
onRecordPause?.()
|
|
428
|
+
} catch {
|
|
429
|
+
// recorder already inactive — nothing to pause
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function resume() {
|
|
434
|
+
if (phase !== 'recording' || paused !== 'paused' || !recorder) return
|
|
435
|
+
try {
|
|
436
|
+
recorder.resume()
|
|
437
|
+
pausedTotalMs += Date.now() - pauseStartedAt
|
|
438
|
+
pauseStartedAt = 0
|
|
439
|
+
paused = 'active'
|
|
440
|
+
stopAnimation()
|
|
441
|
+
raf = requestAnimationFrame(animate)
|
|
442
|
+
onRecordResume?.()
|
|
443
|
+
} catch {
|
|
444
|
+
// recorder already inactive — nothing to resume
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function stop() {
|
|
449
|
+
if (phase !== 'recording' || !recorder) return
|
|
450
|
+
if (pauseStartedAt) {
|
|
451
|
+
pausedTotalMs += Date.now() - pauseStartedAt
|
|
452
|
+
pauseStartedAt = 0
|
|
453
|
+
}
|
|
454
|
+
clearTimers()
|
|
455
|
+
stopAnimation()
|
|
456
|
+
if (recorder.state !== 'inactive') {
|
|
457
|
+
phase = 'processing'
|
|
458
|
+
recorder.stop()
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function cancel() {
|
|
463
|
+
if (phase !== 'recording') return
|
|
464
|
+
cleanup()
|
|
465
|
+
phase = 'idle'
|
|
466
|
+
onRecordCancel?.()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function finalize() {
|
|
470
|
+
if (wasCancelled || chunks.length === 0) {
|
|
471
|
+
if (!wasCancelled) {
|
|
472
|
+
// Zero chunks (e.g. stopped before any data arrived) — fail loud, not a silent no-op.
|
|
473
|
+
phase = 'idle'
|
|
474
|
+
errorMessage = 'Recording was too short — nothing was captured. Try again.'
|
|
475
|
+
onError?.(errorMessage)
|
|
476
|
+
}
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
phase = 'processing'
|
|
480
|
+
const blob = new Blob(chunks, { type: mimeType })
|
|
481
|
+
const result: AudioRecordingResult = {
|
|
482
|
+
blob,
|
|
483
|
+
mimeType,
|
|
484
|
+
durationSeconds: Math.max(1, Math.round(elapsedMs() / 1000)),
|
|
485
|
+
sizeBytes: blob.size
|
|
486
|
+
}
|
|
487
|
+
cleanup()
|
|
488
|
+
phase = 'done'
|
|
489
|
+
if (onRecordStop) {
|
|
490
|
+
try {
|
|
491
|
+
await onRecordStop(result)
|
|
492
|
+
} catch (err) {
|
|
493
|
+
errorMessage = `Recording kept, but the handler failed: ${err instanceof Error ? err.message : String(err)}`
|
|
494
|
+
onError?.(errorMessage)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Return a finished/errored recorder to idle so the host can record again. */
|
|
500
|
+
export function reset() {
|
|
501
|
+
cleanup()
|
|
502
|
+
phase = 'idle'
|
|
503
|
+
errorMessage = ''
|
|
504
|
+
permissionDenied = false
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function retryAfterError() {
|
|
508
|
+
errorMessage = ''
|
|
509
|
+
permissionDenied = false
|
|
510
|
+
phase = 'idle'
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
onMount(() => {
|
|
514
|
+
updateCanvasColors()
|
|
515
|
+
const unwatch = watchThemeChanges(() => {
|
|
516
|
+
updateCanvasColors()
|
|
517
|
+
clearCanvas()
|
|
518
|
+
})
|
|
519
|
+
const onResize = () => {
|
|
520
|
+
lastCanvasWidth = 0
|
|
521
|
+
if (canvas) sizeCanvas()
|
|
522
|
+
}
|
|
523
|
+
window.addEventListener('resize', onResize)
|
|
524
|
+
return () => {
|
|
525
|
+
window.removeEventListener('resize', onResize)
|
|
526
|
+
unwatch()
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
// Space toggles pause/resume while recording (chimaro behaviour).
|
|
531
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
532
|
+
if (e.code === 'Space' && phase === 'recording' && variant === 'large') {
|
|
533
|
+
e.preventDefault()
|
|
534
|
+
if (paused === 'paused') resume()
|
|
535
|
+
else pause()
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
onDestroy(() => {
|
|
540
|
+
cleanup()
|
|
541
|
+
})
|
|
542
|
+
</script>
|
|
543
|
+
|
|
544
|
+
<svelte:document onkeydown={handleKeydown} />
|
|
545
|
+
|
|
546
|
+
<div class={className}>
|
|
547
|
+
{#if variant === 'large'}
|
|
548
|
+
<div class="flex flex-col items-center gap-3">
|
|
549
|
+
{#if phase === 'processing'}
|
|
550
|
+
<div class="badge badge-primary badge-lg gap-2">
|
|
551
|
+
<span class="loading loading-spinner loading-xs"></span>
|
|
552
|
+
Processing…
|
|
553
|
+
</div>
|
|
554
|
+
{:else if phase === 'done'}
|
|
555
|
+
<div class="badge badge-success badge-lg">✓ Complete</div>
|
|
556
|
+
{:else if phase === 'recording' && paused === 'paused'}
|
|
557
|
+
<div class="badge badge-warning badge-lg">⏸ Paused</div>
|
|
558
|
+
{:else if phase === 'recording'}
|
|
559
|
+
<div class="badge badge-error badge-lg animate-pulse">● Recording</div>
|
|
560
|
+
{:else}
|
|
561
|
+
<div class="badge badge-neutral badge-lg">Ready to Record</div>
|
|
562
|
+
{/if}
|
|
563
|
+
|
|
564
|
+
{#if phase === 'recording' || phase === 'processing'}
|
|
565
|
+
<div class="font-mono text-5xl font-bold">{formatDuration(elapsedSec)}</div>
|
|
566
|
+
<div class="text-base-content/60 font-mono text-sm">{formatFileSize(sizeBytes)}</div>
|
|
567
|
+
{/if}
|
|
568
|
+
|
|
569
|
+
{#if phase === 'recording'}
|
|
570
|
+
<div class="relative flex min-h-[96px] items-center justify-center">
|
|
571
|
+
{#if paused !== 'paused'}
|
|
572
|
+
<div class="audio-recorder-pulse"></div>
|
|
573
|
+
{/if}
|
|
574
|
+
<canvas bind:this={canvas} class="border-base-300 bg-base-100 z-10 rounded border"></canvas>
|
|
575
|
+
</div>
|
|
576
|
+
<div class="flex min-h-[20px] items-center gap-2">
|
|
577
|
+
<span class="text-base-content/60 text-xs">Volume:</span>
|
|
578
|
+
<div class="bg-base-300 h-1 w-16 overflow-hidden rounded-full">
|
|
579
|
+
<div
|
|
580
|
+
class="h-full rounded-full bg-gradient-to-r from-success via-warning to-error"
|
|
581
|
+
style="width: {Math.min(audioLevel, 100)}%"
|
|
582
|
+
></div>
|
|
583
|
+
</div>
|
|
584
|
+
</div>
|
|
585
|
+
<div class="mt-2 flex items-center gap-3">
|
|
586
|
+
{#if paused === 'paused'}
|
|
587
|
+
<button
|
|
588
|
+
type="button"
|
|
589
|
+
class="btn btn-success btn-circle"
|
|
590
|
+
onclick={resume}
|
|
591
|
+
aria-label="Resume recording"
|
|
592
|
+
>
|
|
593
|
+
<MediaCaptureIcon name="play" size={20} class="fill-current" />
|
|
594
|
+
</button>
|
|
595
|
+
{:else}
|
|
596
|
+
<button
|
|
597
|
+
type="button"
|
|
598
|
+
class="btn btn-warning btn-circle"
|
|
599
|
+
onclick={pause}
|
|
600
|
+
aria-label="Pause recording"
|
|
601
|
+
>
|
|
602
|
+
<MediaCaptureIcon name="pause" size={20} />
|
|
603
|
+
</button>
|
|
604
|
+
{/if}
|
|
605
|
+
<button
|
|
606
|
+
type="button"
|
|
607
|
+
class="btn btn-primary btn-circle"
|
|
608
|
+
onclick={stop}
|
|
609
|
+
aria-label="Stop and save recording"
|
|
610
|
+
>
|
|
611
|
+
<MediaCaptureIcon name="square" size={20} class="fill-current" />
|
|
612
|
+
</button>
|
|
613
|
+
</div>
|
|
614
|
+
<button type="button" class="btn btn-ghost btn-sm text-error" onclick={cancel}>
|
|
615
|
+
<MediaCaptureIcon name="x" size={12} />
|
|
616
|
+
Cancel
|
|
617
|
+
</button>
|
|
618
|
+
<p class="text-base-content/45 text-xs">Space pauses / resumes</p>
|
|
619
|
+
{:else if phase === 'idle'}
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
class="btn btn-error btn-circle btn-xl hover:btn-primary transition-all"
|
|
623
|
+
{disabled}
|
|
624
|
+
onclick={start}
|
|
625
|
+
aria-label="Start voice recording"
|
|
626
|
+
>
|
|
627
|
+
<MediaCaptureIcon name="mic" size={24} />
|
|
628
|
+
</button>
|
|
629
|
+
{/if}
|
|
630
|
+
|
|
631
|
+
{#if errorMessage}
|
|
632
|
+
<div class="alert alert-warning w-full max-w-md text-sm" role="status" aria-live="polite">
|
|
633
|
+
<span>{errorMessage}</span>
|
|
634
|
+
{#if phase === 'idle'}
|
|
635
|
+
<button type="button" class="btn btn-xs" onclick={retryAfterError}>Dismiss</button>
|
|
636
|
+
{/if}
|
|
637
|
+
</div>
|
|
638
|
+
{/if}
|
|
639
|
+
{#if permissionDenied}
|
|
640
|
+
<p class="text-base-content/50 max-w-md text-center text-xs">
|
|
641
|
+
Your browser blocked microphone access. Enable it from the lock icon in the address bar
|
|
642
|
+
(or site settings), then press the record button again.
|
|
643
|
+
</p>
|
|
644
|
+
{/if}
|
|
645
|
+
</div>
|
|
646
|
+
{:else}
|
|
647
|
+
<!-- small: chat-inline pill -->
|
|
648
|
+
<div class="flex items-center gap-2">
|
|
649
|
+
{#if phase === 'idle'}
|
|
650
|
+
{#if supported}
|
|
651
|
+
<button
|
|
652
|
+
type="button"
|
|
653
|
+
class="btn btn-ghost btn-circle btn-sm hover:btn-primary transition-all"
|
|
654
|
+
{disabled}
|
|
655
|
+
onclick={start}
|
|
656
|
+
aria-label="Start voice recording"
|
|
657
|
+
title="Start voice recording"
|
|
658
|
+
>
|
|
659
|
+
<MediaCaptureIcon name="mic" size={14} />
|
|
660
|
+
</button>
|
|
661
|
+
{/if}
|
|
662
|
+
{:else if phase === 'recording'}
|
|
663
|
+
<div class="bg-base-100 border-base-200 flex items-center gap-2 rounded-lg border p-1.5 shadow-sm">
|
|
664
|
+
{#if paused === 'paused'}
|
|
665
|
+
<button
|
|
666
|
+
type="button"
|
|
667
|
+
class="btn btn-success btn-circle btn-sm"
|
|
668
|
+
onclick={resume}
|
|
669
|
+
aria-label="Resume recording"
|
|
670
|
+
>
|
|
671
|
+
<MediaCaptureIcon name="play" size={14} class="fill-current" />
|
|
672
|
+
</button>
|
|
673
|
+
{:else}
|
|
674
|
+
<button
|
|
675
|
+
type="button"
|
|
676
|
+
class="btn btn-warning btn-circle btn-sm"
|
|
677
|
+
onclick={pause}
|
|
678
|
+
aria-label="Pause recording"
|
|
679
|
+
>
|
|
680
|
+
<MediaCaptureIcon name="pause" size={14} />
|
|
681
|
+
</button>
|
|
682
|
+
{/if}
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
class="btn btn-primary btn-circle btn-sm"
|
|
686
|
+
onclick={stop}
|
|
687
|
+
aria-label="Stop and save recording"
|
|
688
|
+
>
|
|
689
|
+
<MediaCaptureIcon name="square" size={14} class="fill-current" />
|
|
690
|
+
</button>
|
|
691
|
+
<span class="text-base-content/60 w-9 text-center font-mono text-xs">
|
|
692
|
+
{formatDuration(elapsedSec)}
|
|
693
|
+
</span>
|
|
694
|
+
<div class="bg-base-300 h-1 w-8 overflow-hidden rounded-full">
|
|
695
|
+
<div
|
|
696
|
+
class="h-full rounded-full bg-gradient-to-r from-success via-warning to-error"
|
|
697
|
+
style="width: {Math.min(audioLevel, 100)}%"
|
|
698
|
+
></div>
|
|
699
|
+
</div>
|
|
700
|
+
<button
|
|
701
|
+
type="button"
|
|
702
|
+
class="btn btn-ghost btn-xs text-error"
|
|
703
|
+
onclick={cancel}
|
|
704
|
+
aria-label="Cancel recording"
|
|
705
|
+
>
|
|
706
|
+
<MediaCaptureIcon name="x" size={10} />
|
|
707
|
+
</button>
|
|
708
|
+
</div>
|
|
709
|
+
{:else if phase === 'processing'}
|
|
710
|
+
<div class="flex items-center gap-2" role="status" aria-live="polite">
|
|
711
|
+
<MediaCaptureIcon name="loader" size={14} class="animate-spin" />
|
|
712
|
+
<span class="text-base-content/60 text-xs">Processing…</span>
|
|
713
|
+
</div>
|
|
714
|
+
{:else if phase === 'done'}
|
|
715
|
+
<span class="text-success text-xs">✓ Saved</span>
|
|
716
|
+
<button type="button" class="btn btn-ghost btn-xs" onclick={reset}>Record again</button>
|
|
717
|
+
{/if}
|
|
718
|
+
{#if errorMessage && variant === 'small'}
|
|
719
|
+
<span class="text-warning text-xs" role="status" aria-live="polite">{errorMessage}</span>
|
|
720
|
+
{/if}
|
|
721
|
+
</div>
|
|
722
|
+
{/if}
|
|
723
|
+
</div>
|
|
724
|
+
|
|
725
|
+
<style>
|
|
726
|
+
.audio-recorder-pulse {
|
|
727
|
+
position: absolute;
|
|
728
|
+
inset: -8px;
|
|
729
|
+
border-radius: 12px;
|
|
730
|
+
background: color-mix(in oklch, var(--color-error) 10%, transparent);
|
|
731
|
+
animation: audio-recorder-pulse 2s ease-in-out infinite;
|
|
732
|
+
}
|
|
733
|
+
@keyframes audio-recorder-pulse {
|
|
734
|
+
0%,
|
|
735
|
+
100% {
|
|
736
|
+
transform: scale(1);
|
|
737
|
+
opacity: 0.3;
|
|
738
|
+
}
|
|
739
|
+
50% {
|
|
740
|
+
transform: scale(1.05);
|
|
741
|
+
opacity: 0.6;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
@media (prefers-reduced-motion: reduce) {
|
|
745
|
+
.audio-recorder-pulse {
|
|
746
|
+
animation: none;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
</style>
|