@lincyaw/workbench 0.0.0-stage → 0.2.0-rc.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.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +66 -2
  3. package/package.json +68 -3
  4. package/src/MediaClipEditor.tsx +122 -0
  5. package/src/StepProductionEditor.tsx +130 -0
  6. package/src/approvals.tsx +60 -0
  7. package/src/assistant-mascot.ts +62 -0
  8. package/src/audio-recording-messages.ts +44 -0
  9. package/src/audio-recording.ts +192 -0
  10. package/src/audio-wav.ts +25 -0
  11. package/src/audio-waveform.ts +110 -0
  12. package/src/audition.ts +84 -0
  13. package/src/authoring-client.ts +136 -0
  14. package/src/authoring-draft-fragment.ts +28 -0
  15. package/src/base-controls.tsx +47 -0
  16. package/src/caption-timing-drafts.ts +61 -0
  17. package/src/caption-timing-messages.ts +14 -0
  18. package/src/caption-timing.css +20 -0
  19. package/src/caption-timing.ts +14 -0
  20. package/src/component-draft-actions.ts +54 -0
  21. package/src/component-draft-inspection.ts +35 -0
  22. package/src/component-properties.css +24 -0
  23. package/src/component-properties.ts +45 -0
  24. package/src/component-property-drafts.ts +273 -0
  25. package/src/component-property-messages.ts +31 -0
  26. package/src/components.tsx +631 -0
  27. package/src/context.tsx +27 -0
  28. package/src/diagnostics.ts +113 -0
  29. package/src/document-draft-store.ts +179 -0
  30. package/src/document-drafts.ts +178 -0
  31. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  32. package/src/document-editor/ComponentMaterials.tsx +62 -0
  33. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  34. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  35. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  36. package/src/document-editor/DocumentComments.tsx +282 -0
  37. package/src/document-editor/DocumentEditor.tsx +192 -0
  38. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  39. package/src/document-editor/NarrationEditor.tsx +110 -0
  40. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  41. package/src/document-editor/authoring.tsx +259 -0
  42. package/src/document-editor/block-playback.ts +90 -0
  43. package/src/document-editor/block-preview-messages.ts +13 -0
  44. package/src/document-editor/blocknote-messages.ts +98 -0
  45. package/src/document-editor/clipboard.ts +40 -0
  46. package/src/document-editor/colors.ts +57 -0
  47. package/src/document-editor/comment-draft-actions.ts +58 -0
  48. package/src/document-editor/comment-draft-inspection.ts +38 -0
  49. package/src/document-editor/comment-drafts.ts +361 -0
  50. package/src/document-editor/comments.ts +410 -0
  51. package/src/document-editor/file-input.ts +71 -0
  52. package/src/document-editor/focus.ts +54 -0
  53. package/src/document-editor/inline.ts +104 -0
  54. package/src/document-editor/math.tsx +63 -0
  55. package/src/document-editor/messages.ts +14 -0
  56. package/src/document-editor/narration-schema.tsx +85 -0
  57. package/src/document-editor/narration.ts +37 -0
  58. package/src/document-editor/popover.tsx +107 -0
  59. package/src/document-editor/projection.ts +423 -0
  60. package/src/document-editor/rich-text.ts +152 -0
  61. package/src/document-editor/schema.tsx +117 -0
  62. package/src/document-editor/selection.ts +152 -0
  63. package/src/document-editor/style.css +9 -0
  64. package/src/document-editor/styles.d.ts +1 -0
  65. package/src/document-editor/table-identities.ts +67 -0
  66. package/src/document-preview.ts +112 -0
  67. package/src/document-return-point.ts +59 -0
  68. package/src/document-session.ts +678 -0
  69. package/src/document-timing-drafts.ts +61 -0
  70. package/src/document-voices.ts +2 -0
  71. package/src/history.ts +167 -0
  72. package/src/index.ts +36 -0
  73. package/src/jobs.ts +111 -0
  74. package/src/mascot-preference.ts +37 -0
  75. package/src/media-drafts.ts +83 -0
  76. package/src/media-messages.ts +53 -0
  77. package/src/media-time.ts +12 -0
  78. package/src/messages.ts +781 -0
  79. package/src/outline.ts +97 -0
  80. package/src/panels/ChangeTrail.tsx +79 -0
  81. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  82. package/src/panels/DocumentView.tsx +462 -0
  83. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  84. package/src/panels/DocumentVoiceField.tsx +35 -0
  85. package/src/panels/ExportPanel.tsx +133 -0
  86. package/src/panels/HistoryPanel.tsx +88 -0
  87. package/src/panels/HomePanel.tsx +184 -0
  88. package/src/panels/PageDeleteDialog.tsx +33 -0
  89. package/src/panels/PageRenameDialog.tsx +27 -0
  90. package/src/panels/PreviewPanel.tsx +79 -0
  91. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  92. package/src/panels/ProductionMediaClips.tsx +156 -0
  93. package/src/panels/ProductionPanel.tsx +272 -0
  94. package/src/panels/ProductionRecording.tsx +107 -0
  95. package/src/panels/ProductionRecordingRange.tsx +118 -0
  96. package/src/panels/ProductionTimeline.tsx +62 -0
  97. package/src/panels/ProductionTranscription.tsx +140 -0
  98. package/src/panels/SourceDocumentPanel.tsx +215 -0
  99. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  100. package/src/panels/TranscriptionAdoption.tsx +147 -0
  101. package/src/panels/VoiceCloneDialog.tsx +152 -0
  102. package/src/panels/VoicePreviewDialog.tsx +74 -0
  103. package/src/panels/VoiceScopeField.tsx +30 -0
  104. package/src/panels/VoiceoverDialog.tsx +216 -0
  105. package/src/places.ts +102 -0
  106. package/src/preview-playback.ts +125 -0
  107. package/src/preview-pool.ts +320 -0
  108. package/src/preview.ts +110 -0
  109. package/src/production-messages.ts +151 -0
  110. package/src/production-playback.ts +109 -0
  111. package/src/production-voice-playback.ts +114 -0
  112. package/src/recording-adoption.ts +33 -0
  113. package/src/schema-form/PmSchemaForm.tsx +187 -0
  114. package/src/schema-form/context.ts +26 -0
  115. package/src/schema-form/fields.tsx +59 -0
  116. package/src/schema-form/templates.tsx +50 -0
  117. package/src/schema-form/validator.ts +36 -0
  118. package/src/schema-form/widgets.tsx +60 -0
  119. package/src/shortcuts.ts +8 -0
  120. package/src/sortable-list.tsx +201 -0
  121. package/src/speech-settings-messages.ts +40 -0
  122. package/src/step-production.css +5 -0
  123. package/src/step-production.ts +51 -0
  124. package/src/styles.css +1025 -0
  125. package/src/telemetry.ts +152 -0
  126. package/src/transcription-messages.ts +26 -0
  127. package/src/transcription-session.ts +159 -0
  128. package/src/use-recording-adoption.ts +84 -0
  129. package/src/voice-clone-messages.ts +92 -0
  130. package/src/voice-drafts.ts +89 -0
  131. package/src/voice-messages.ts +146 -0
  132. package/src/voice-snapshot.ts +55 -0
  133. package/src/voiceover-jobs.ts +87 -0
  134. package/src/workbench.ts +2328 -0
  135. package/src/workspace-client.ts +480 -0
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react'
2
+ import type { ApprovalRequest } from '@lincyaw/kernel'
3
+ import { PmButton, PmNotice, PmPopover } from './components.tsx'
4
+ import { useMessages, useWorkbench } from './context.tsx'
5
+ import { WorkspaceServerError } from './workspace-client.ts'
6
+
7
+ interface Pending { id: string, request: ApprovalRequest }
8
+
9
+ /**
10
+ * The host's operations waiting for the author, such as an external MCP client's import: shown only while one is
11
+ * pending, answered here as in the viewer's "Approvals". The host times them out; a late answer is reported, not retried.
12
+ */
13
+ export function WorkbenchApprovals({ density = 'compact' }: { density?: 'compact' | 'touch' }) {
14
+ const { client } = useWorkbench()
15
+ const t = useMessages()
16
+ const [pending, setPending] = useState<Pending[]>([])
17
+ const [open, setOpen] = useState(false)
18
+ const [answering, setAnswering] = useState<string>()
19
+ const [notice, setNotice] = useState<string>()
20
+ useEffect(() => client.subscribeApprovals((event) => {
21
+ if (event.type === 'reset') setPending([])
22
+ else if (event.type === 'settled') setPending(list => list.filter(item => item.id !== event.id))
23
+ else setPending(list => list.some(item => item.id === event.id) ? list : [...list, { id: event.id, request: event.request }])
24
+ }), [client])
25
+ useEffect(() => { if (!pending.length) setOpen(false) }, [pending.length])
26
+ if (!pending.length) return null
27
+ const answer = (id: string, allow: boolean) => {
28
+ setAnswering(id); setNotice(undefined)
29
+ void client.answerApproval(id, allow).then(
30
+ () => setPending(list => list.filter(item => item.id !== id)),
31
+ (error: unknown) => {
32
+ if (error instanceof WorkspaceServerError && error.status === 404) {
33
+ setPending(list => list.filter(item => item.id !== id))
34
+ setNotice(t.approvalExpired)
35
+ } else setNotice(t.approvalFailed)
36
+ },
37
+ ).finally(() => setAnswering(current => current === id ? undefined : current))
38
+ }
39
+ return (
40
+ <PmPopover open={open} onOpenChange={setOpen} label={t.approvalsTitle} name="pmw-approvals" trigger={
41
+ <PmButton variant="accent" density={density}>{t.approvals(pending.length)}</PmButton>
42
+ }>
43
+ <h2 className="pmw-approvals__heading">{t.approvalsTitle}</h2>
44
+ <ul className="pmw-approvals__list">
45
+ {pending.map(({ id, request }) => (
46
+ <li key={id} className="pmw-approvals__item" data-approval={id}>
47
+ <p className="pmw-approvals__title">{request.title}</p>
48
+ <p className="pmw-muted">{t.approvalFrom(t.approvalOrigin[request.from] ?? t.approvalOtherOrigin)}</p>
49
+ {request.detail && <p className="pmw-approvals__detail">{request.detail}</p>}
50
+ <div className="pmw-approvals__actions">
51
+ <PmButton density={density} disabled={answering === id} onClick={() => answer(id, false)}>{t.approvalDeny}</PmButton>
52
+ <PmButton variant="primary" density={density} loading={answering === id ? true : undefined} disabled={answering === id} onClick={() => answer(id, true)}>{t.approvalAllow}</PmButton>
53
+ </div>
54
+ </li>
55
+ ))}
56
+ </ul>
57
+ {notice && <PmNotice tone="warn">{notice}</PmNotice>}
58
+ </PmPopover>
59
+ )
60
+ }
@@ -0,0 +1,62 @@
1
+ // What the kitten beside the assistant does, from what the assistant is doing: asking the author to approve
2
+ // something, reading the document it was asked about, writing into it, thinking, just finished, or idle (sitting,
3
+ // then lying down, then asleep the longer nothing happens).
4
+ import { useEffect, useState } from 'react'
5
+ import type { MascotAction } from '@lincyaw/design/mascot'
6
+ import { useWorkbench, useWorkbenchState } from './context.tsx'
7
+
8
+ const DONE_MS = 2400
9
+ const LOAF_MS = 90_000
10
+ const SLEEP_MS = 300_000
11
+
12
+ export interface AssistantActivity {
13
+ /** The assistant's turn is running. */
14
+ running: boolean
15
+ /** It holds a review of the open document: still working in it, and how many changes it has made. */
16
+ review?: { running: boolean, changes: number }
17
+ /** Operations wait for the author's approval. */
18
+ approvals: number
19
+ /** How long ago the assistant last stopped, in milliseconds (Infinity if it has not run). */
20
+ sinceStopped: number
21
+ }
22
+
23
+ export function assistantMascotAction(a: AssistantActivity): MascotAction {
24
+ if (a.approvals > 0) return 'ask'
25
+ if (a.review?.running) return a.review.changes > 0 ? 'write' : 'read'
26
+ if (a.running) return 'think'
27
+ if (a.review && a.review.changes > 0) return 'ask'
28
+ if (a.sinceStopped < DONE_MS) return 'done'
29
+ if (a.sinceStopped > SLEEP_MS) return 'sleep'
30
+ if (a.sinceStopped > LOAF_MS) return 'loaf'
31
+ return 'sit'
32
+ }
33
+
34
+ /** The kitten's action for the assistant, given whether its turn is running; follows the workbench's review and the
35
+ * operations waiting for approval, and moves on by itself as time passes. */
36
+ export function useAssistantMascot(running: boolean): MascotAction {
37
+ const { client } = useWorkbench()
38
+ // two plain values rather than one object, so the selection compares equal while nothing changes
39
+ const reviewRunning = useWorkbenchState(state => state.review?.running)
40
+ const changes = useWorkbenchState(state => state.review?.changes.length)
41
+ const review = reviewRunning === undefined ? undefined : { running: reviewRunning, changes: changes ?? 0 }
42
+ const [approvals, setApprovals] = useState<string[]>([])
43
+ const [stoppedAt, setStoppedAt] = useState<number>(() => performance.now() - LOAF_MS / 2)
44
+ const [, setNow] = useState(0)
45
+ const busy = running || Boolean(review?.running)
46
+ useEffect(() => client.subscribeApprovals(event => {
47
+ if (event.type === 'reset') setApprovals([])
48
+ else if (event.type === 'settled') setApprovals(list => list.filter(id => id !== event.id))
49
+ else setApprovals(list => list.includes(event.id) ? list : [...list, event.id])
50
+ }), [client])
51
+ useEffect(() => { if (!busy) setStoppedAt(performance.now()) }, [busy])
52
+ // Wake up at the next moment the idle kitten changes what it does.
53
+ const since = performance.now() - stoppedAt
54
+ useEffect(() => {
55
+ if (busy) return
56
+ const next = [DONE_MS, LOAF_MS, SLEEP_MS].find(ms => ms > since)
57
+ if (next === undefined) return
58
+ const timer = setTimeout(() => setNow(performance.now()), next - since + 50)
59
+ return () => clearTimeout(timer)
60
+ }, [busy, since])
61
+ return assistantMascotAction({ running, review, approvals: approvals.length, sinceStopped: busy ? 0 : since })
62
+ }
@@ -0,0 +1,44 @@
1
+ import type { Locale } from './messages.ts'
2
+
3
+ const zh = {
4
+ trim: '录音区间', captionReview: '字幕尚未按裁剪后的录音对齐,请核对。',
5
+ title: '原音频', source: '本步原音', choose: '选择已保存音频', removedAudio: '原音频已移除', adopt: '采用为本步配音', adopted: '已采用', retryAdoption: '重试采用', cancelAdoption: '取消采用', adoptionSourceChanged: '文稿已更新,请重试。', adoptionFailed: '无法采用这段音频,请重试。', start: '开始录音', stop: '停止录音', cancel: '取消', cancelTake: '放弃本次录音', rerecord: '重新录音',
6
+ audition: '试听', stopAudition: '停止试听', save: '保存为音频素材', saving: '正在保存原音…', retry: '重试', discard: '放弃候选',
7
+ ready: '原音候选 · 尚未保存', imported: '已保存为音频素材', requesting: '正在等待麦克风许可', recording: '正在录音', stopping: '正在完成录音…',
8
+ cancelSave: '取消保存', currentSource: '文稿尚未保存,请先完成当前输入。', saveFailed: '无法保存原音。录音已保留,请重试。',
9
+ discardTitle: '放弃这份原音?', discardHint: '未保存的录音将被删除,已保存的音频素材不受影响。', keep: '保留原音',
10
+ failures: {
11
+ 'recording-context': '当前环境无法访问麦克风。请使用安全连接,或继续导入音频。',
12
+ 'recording-unsupported': '此浏览器无法录制受支持的音频格式。仍可导入音频。',
13
+ 'recording-permission': '麦克风未获许可。请检查浏览器权限后重试。',
14
+ 'recording-device': '没有可用的麦克风。', 'recording-device-busy': '麦克风当前无法使用,请检查设备后重试。',
15
+ 'recording-device-unsupported': '当前浏览器或设备不支持此录音请求。仍可导入音频。',
16
+ 'recording-device-ended': '麦克风连接已结束,已保留录到的原音。',
17
+ 'recording-failed': '无法完成本次录音。已有原音仍保留。', 'recording-empty': '本次没有录到音频,请重试。',
18
+ 'recording-size': '录音超过 16 MiB。请缩短录音后重试,已有候选仍保留。',
19
+ 'recording-duration': '已达到十分钟录制上限,录音已停止。',
20
+ 'audition-audio': '原音无法播放。请检查音频或重新录制。', 'audition-request': '无法开始试听,请重试。',
21
+ } as Record<string, string>,
22
+ }
23
+ type Messages = typeof zh
24
+ const en: Messages = {
25
+ trim: 'Recording range', captionReview: 'Captions are not aligned to the trimmed recording. Review them.',
26
+ title: 'Original audio', source: 'Original audio for this step', choose: 'Choose Saved Audio', removedAudio: 'Original audio was removed', adopt: 'Use as Step Voiceover', adopted: 'Applied', retryAdoption: 'Retry', cancelAdoption: 'Cancel', adoptionSourceChanged: 'The document changed. Retry.', adoptionFailed: 'Couldn’t use this audio. Try again.', start: 'Start Recording', stop: 'Stop Recording', cancel: 'Cancel', cancelTake: 'Discard This Recording', rerecord: 'Record Again',
27
+ audition: 'Play Sample', stopAudition: 'Stop Sample', save: 'Save as Audio Asset', saving: 'Saving original audio…', retry: 'Retry', discard: 'Discard Candidate',
28
+ ready: 'Original audio candidate · Not saved', imported: 'Saved as an audio asset', requesting: 'Waiting for microphone permission', recording: 'Recording', stopping: 'Finishing recording…',
29
+ cancelSave: 'Cancel', currentSource: 'Finish and save the current document input first.', saveFailed: 'Couldn’t save the original audio. The recording is kept; try again.',
30
+ discardTitle: 'Discard this original audio?', discardHint: 'The recording that is not saved will be deleted. Saved audio assets are kept.', keep: 'Keep Original Audio',
31
+ failures: {
32
+ 'recording-context': 'Microphone access is unavailable here. Use a secure connection or import an audio file.',
33
+ 'recording-unsupported': 'This browser cannot record a supported audio format. Audio file import is still available.',
34
+ 'recording-permission': 'Microphone permission was not granted. Check browser permissions and retry.',
35
+ 'recording-device': 'No microphone is available.', 'recording-device-busy': 'The microphone is unavailable. Check the device and retry.',
36
+ 'recording-device-unsupported': 'This browser or device does not support the recording request. Audio file import is still available.',
37
+ 'recording-device-ended': 'The microphone disconnected. Recorded audio is retained.',
38
+ 'recording-failed': 'Couldn’t finish this recording. Any previous original audio is kept.', 'recording-empty': 'No audio was recorded. Retry.',
39
+ 'recording-size': 'The recording exceeds 16 MiB. Record a shorter take; any previous candidate is retained.',
40
+ 'recording-duration': 'The ten-minute recording limit was reached. Recording stopped.',
41
+ 'audition-audio': 'This audio could not be played. Check it or record again.', 'audition-request': 'Couldn’t start listening. Try again.',
42
+ },
43
+ }
44
+ export const AUDIO_RECORDING_MESSAGES: Record<Locale, Messages> = { 'zh-CN': zh, en }
@@ -0,0 +1,192 @@
1
+ /** A microphone take remains an in-memory candidate until the author saves its original bytes. */
2
+ export interface RecordingCandidate { file: File, recordedAt: string, elapsedMs: number, assetId?: string }
3
+ export interface AudioRecordingState {
4
+ phase: 'idle' | 'requesting' | 'recording' | 'stopping' | 'ready' | 'failed'
5
+ candidate?: RecordingCandidate
6
+ elapsedMs: number
7
+ error?: string
8
+ }
9
+ const MAX_BYTES = 16 << 20
10
+ const MAX_DURATION_MS = 10 * 60_000
11
+ const MIME_TYPES = ['audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4']
12
+
13
+ export function recordingUnavailable(): string | undefined {
14
+ if (!globalThis.isSecureContext || !navigator.mediaDevices?.getUserMedia) return 'recording-context'
15
+ if (typeof MediaRecorder === 'undefined' || !MIME_TYPES.some(type => MediaRecorder.isTypeSupported(type))) return 'recording-unsupported'
16
+ return undefined
17
+ }
18
+
19
+ interface Take {
20
+ recorder: MediaRecorder
21
+ stream: MediaStream
22
+ chunks: Blob[]
23
+ bytes: number
24
+ started: number
25
+ recordedAt: string
26
+ discard: boolean
27
+ error?: string
28
+ interval?: ReturnType<typeof setInterval>
29
+ release: () => void
30
+ ended: () => void
31
+ }
32
+
33
+ export class AudioRecording {
34
+ #state: AudioRecordingState = { phase: 'idle', elapsedMs: 0 }
35
+ readonly #listeners = new Set<() => void>()
36
+ #generation = 0
37
+ #disposed = false
38
+ #take?: Take
39
+ #release?: () => void
40
+ #warning = false
41
+ readonly #beforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
42
+
43
+ snapshot = (): AudioRecordingState => this.#state
44
+ subscribe = (listener: () => void): (() => void) => { if (this.#disposed) return () => {}; this.#listeners.add(listener); return () => { this.#listeners.delete(listener) } }
45
+ #set(state: AudioRecordingState): void {
46
+ if (this.#disposed) return
47
+ this.#state = state
48
+ const warning = ['requesting', 'recording', 'stopping'].includes(state.phase) || Boolean(state.candidate && !state.candidate.assetId)
49
+ if (warning !== this.#warning) {
50
+ this.#warning = warning
51
+ if (warning) window.addEventListener('beforeunload', this.#beforeUnload)
52
+ else window.removeEventListener('beforeunload', this.#beforeUnload)
53
+ }
54
+ for (const listener of this.#listeners) listener()
55
+ }
56
+ #settle(error?: string): void {
57
+ this.#set({ phase: error ? 'failed' : this.#state.candidate ? 'ready' : 'idle', candidate: this.#state.candidate, elapsedMs: this.#state.candidate?.elapsedMs ?? 0, ...error ? { error } : {} })
58
+ }
59
+
60
+ async start(prepare: () => Promise<() => void>): Promise<void> {
61
+ if (this.#disposed || this.#take || this.#state.phase === 'requesting') return
62
+ const unavailable = recordingUnavailable()
63
+ if (unavailable) { this.#settle(unavailable); return }
64
+ const generation = ++this.#generation
65
+ this.#set({ ...this.#state, phase: 'requesting', error: undefined })
66
+ let stream: MediaStream | undefined
67
+ let release: (() => void) | undefined
68
+ try {
69
+ release = await prepare()
70
+ if (generation !== this.#generation) { release(); return }
71
+ this.#release = release
72
+ stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: { ideal: 1 } }, video: false })
73
+ if (generation !== this.#generation) { stream.getTracks().forEach(track => track.stop()); return }
74
+ const mimeType = MIME_TYPES.find(type => MediaRecorder.isTypeSupported(type))!
75
+ const recorder = new MediaRecorder(stream, { mimeType, audioBitsPerSecond: 128_000 })
76
+ const take: Take = {
77
+ recorder, stream, chunks: [], bytes: 0, started: performance.now(), recordedAt: new Date().toISOString(), discard: false,
78
+ release: () => { release?.(); if (this.#release === release) this.#release = undefined },
79
+ ended: () => { if (this.#take === take) this.stop('recording-device-ended') },
80
+ }
81
+ this.#take = take
82
+ recorder.ondataavailable = event => {
83
+ if (this.#take !== take || !event.data.size) return
84
+ take.chunks.push(event.data); take.bytes += event.data.size
85
+ if (take.bytes > MAX_BYTES) this.stop('recording-size')
86
+ }
87
+ recorder.onerror = () => { if (this.#take === take) this.stop('recording-failed') }
88
+ recorder.onstop = () => this.#finish(take)
89
+ for (const track of stream.getTracks()) track.addEventListener('ended', take.ended)
90
+ recorder.start(1000)
91
+ this.#set({ ...this.#state, phase: 'recording', elapsedMs: 0, error: undefined })
92
+ take.interval = setInterval(() => {
93
+ if (this.#take !== take || recorder.state !== 'recording') return
94
+ const elapsedMs = performance.now() - take.started
95
+ this.#set({ ...this.#state, elapsedMs })
96
+ if (elapsedMs >= MAX_DURATION_MS) this.stop('recording-duration')
97
+ }, 1000)
98
+ } catch (error) {
99
+ stream?.getTracks().forEach(track => track.stop())
100
+ release?.(); if (this.#release === release) this.#release = undefined
101
+ if (generation !== this.#generation) return
102
+ this.#take = undefined
103
+ const name = error instanceof DOMException ? error.name : ''
104
+ this.#settle(name === 'NotAllowedError' || name === 'SecurityError' ? 'recording-permission' : name === 'NotFoundError' ? 'recording-device' : name === 'NotReadableError' ? 'recording-device-busy' : name === 'NotSupportedError' ? 'recording-device-unsupported' : 'recording-failed')
105
+ }
106
+ }
107
+
108
+ stop(error?: string): void {
109
+ const take = this.#take
110
+ if (!take) {
111
+ if (this.#state.phase === 'requesting') { this.#generation++; this.#release?.(); this.#release = undefined; this.#settle(error) }
112
+ return
113
+ }
114
+ if (error) take.error ??= error
115
+ clearInterval(take.interval)
116
+ if (take.recorder.state !== 'inactive') {
117
+ this.#set({ ...this.#state, phase: 'stopping' })
118
+ try { take.recorder.stop() }
119
+ catch { take.error ??= 'recording-failed'; this.#finish(take) }
120
+ }
121
+ take.stream.getTracks().forEach(track => track.stop())
122
+ take.release()
123
+ }
124
+
125
+ #finish(take: Take): void {
126
+ if (this.#take !== take) return
127
+ this.#take = undefined
128
+ clearInterval(take.interval)
129
+ for (const track of take.stream.getTracks()) { track.removeEventListener('ended', take.ended); track.stop() }
130
+ take.recorder.ondataavailable = null; take.recorder.onstop = null; take.recorder.onerror = null
131
+ take.release()
132
+ if (take.discard) { this.#settle(); return }
133
+ if (take.error && this.#state.candidate) { this.#settle(take.error); return }
134
+ if (!take.bytes || take.bytes > MAX_BYTES) { this.#settle(take.bytes ? 'recording-size' : 'recording-empty'); return }
135
+ const mime = take.recorder.mimeType || take.chunks[0]!.type
136
+ const extension = mime.includes('ogg') ? 'ogg' : mime.includes('mp4') ? 'm4a' : 'webm'
137
+ const file = new File(take.chunks, `recording-${take.recordedAt.replace(/[:.]/g, '-')}.${extension}`, { type: mime })
138
+ this.#set({ phase: 'ready', candidate: { file, recordedAt: take.recordedAt, elapsedMs: performance.now() - take.started }, elapsedMs: performance.now() - take.started, ...take.error ? { error: take.error } : {} })
139
+ }
140
+
141
+ cancelTake(): void {
142
+ if (this.#take) this.#take.discard = true
143
+ this.stop()
144
+ }
145
+ imported(file: File, assetId: string): void {
146
+ if (this.#state.candidate?.file !== file) return
147
+ this.#set({ ...this.#state, candidate: { ...this.#state.candidate, assetId }, error: undefined })
148
+ }
149
+ forgetImport(assetId: string): void {
150
+ if (this.#state.candidate?.assetId !== assetId) return
151
+ this.#set({ ...this.#state, candidate: { ...this.#state.candidate, assetId: undefined } })
152
+ }
153
+ discard(): void {
154
+ this.cancelTake()
155
+ this.#set({ phase: 'idle', elapsedMs: 0 })
156
+ }
157
+
158
+ dispose(): void {
159
+ if (this.#disposed) return
160
+ this.#disposed = true
161
+ this.#generation++
162
+ const take = this.#take
163
+ if (take) { take.discard = true; this.stop(); this.#finish(take) }
164
+ this.#release?.(); this.#release = undefined
165
+ if (this.#warning) window.removeEventListener('beforeunload', this.#beforeUnload)
166
+ this.#warning = false
167
+ this.#state = { phase: 'idle', elapsedMs: 0 }
168
+ this.#listeners.clear()
169
+ }
170
+ }
171
+
172
+ const recordings = new WeakMap<object, Map<string, AudioRecording>>()
173
+ /** Includes preparation and recorder finalization, when a playback lease alone cannot describe the take. */
174
+ export function documentRecordingActive(owner: object): boolean {
175
+ return [...recordings.get(owner)?.values() ?? []].some(recording => ['requesting', 'recording', 'stopping'].includes(recording.snapshot().phase))
176
+ }
177
+
178
+ /** Volatile UI material, isolated by the real workspace/document identity; never a persisted source model. */
179
+ export function documentRecording(owner: object, identity: string): AudioRecording {
180
+ let documents = recordings.get(owner)
181
+ if (!documents) { documents = new Map(); recordings.set(owner, documents) }
182
+ let recording = documents.get(identity)
183
+ if (!recording) { recording = new AudioRecording(); documents.set(identity, recording) }
184
+ return recording
185
+ }
186
+
187
+ export function disposeDocumentRecordings(owner: object): void {
188
+ const documents = recordings.get(owner)
189
+ recordings.delete(owner)
190
+ for (const recording of documents?.values() ?? []) recording.dispose()
191
+ documents?.clear()
192
+ }
@@ -0,0 +1,25 @@
1
+ /** A recording or an audio file as 16-bit mono WAV, with its length. */
2
+ export interface WavAudio { blob: Blob, seconds: number }
3
+
4
+ /**
5
+ * Decodes any audio the browser plays (a microphone take in WebM, Ogg or MP4; an MP3, M4A or WAV file) and writes
6
+ * it as 16-bit mono WAV at `rate`: the one format every voice-cloning provider takes. Rendering through an offline
7
+ * context mixes the channels down and resamples, with no realtime audio and no permission.
8
+ */
9
+ export async function toWav(source: Blob, rate = 24000): Promise<WavAudio> {
10
+ const decoded = await new OfflineAudioContext(1, 1, rate).decodeAudioData(await source.arrayBuffer())
11
+ const offline = new OfflineAudioContext(1, Math.max(1, Math.ceil(decoded.duration * rate)), rate)
12
+ const node = offline.createBufferSource()
13
+ node.buffer = decoded
14
+ node.connect(offline.destination)
15
+ node.start()
16
+ const samples = (await offline.startRendering()).getChannelData(0)
17
+ const wav = new DataView(new ArrayBuffer(44 + samples.length * 2))
18
+ const text = (at: number, value: string) => { for (let i = 0; i < value.length; i++) wav.setUint8(at + i, value.charCodeAt(i)) }
19
+ text(0, 'RIFF'); wav.setUint32(4, 36 + samples.length * 2, true); text(8, 'WAVE')
20
+ text(12, 'fmt '); wav.setUint32(16, 16, true); wav.setUint16(20, 1, true); wav.setUint16(22, 1, true)
21
+ wav.setUint32(24, rate, true); wav.setUint32(28, rate * 2, true); wav.setUint16(32, 2, true); wav.setUint16(34, 16, true)
22
+ text(36, 'data'); wav.setUint32(40, samples.length * 2, true)
23
+ for (let i = 0; i < samples.length; i++) wav.setInt16(44 + i * 2, Math.round(Math.max(-1, Math.min(1, samples[i]!)) * 32767), true)
24
+ return { blob: new Blob([wav.buffer], { type: 'audio/wav' }), seconds: decoded.duration }
25
+ }
@@ -0,0 +1,110 @@
1
+ /** Read-only peaks from decoded media, never a replacement for the lecture's timing plan. */
2
+ export interface AudioWaveform {
3
+ readonly durationMs: number
4
+ readonly peaks: readonly number[]
5
+ }
6
+
7
+ const MAX_BYTES = 16 * 1024 * 1024
8
+ const MAX_DURATION_MS = 10 * 60 * 1000
9
+ const MAX_PENDING = 128
10
+ const MAX_CACHED = 24
11
+ const CONCURRENCY = 2
12
+
13
+ interface Job {
14
+ key: string
15
+ durationMs: number
16
+ bins: number
17
+ load(signal: AbortSignal): Promise<Blob>
18
+ signal?: AbortSignal
19
+ unlisten(): void
20
+ resolve(value: AudioWaveform): void
21
+ reject(error: unknown): void
22
+ }
23
+
24
+ function aborted(): DOMException { return new DOMException('Waveform reading cancelled', 'AbortError') }
25
+
26
+ async function decode(blob: Blob, durationMs: number, bins: number, signal: AbortSignal): Promise<AudioWaveform> {
27
+ signal.throwIfAborted()
28
+ if (!blob.size || blob.size > MAX_BYTES) throw new Error('waveform-size-limit')
29
+ if (typeof OfflineAudioContext !== 'function') throw new Error('waveform-decoder-unavailable')
30
+ const bytes = await blob.arrayBuffer()
31
+ signal.throwIfAborted()
32
+ // Offline decoding never starts an AudioContext destination or requests permission to play.
33
+ const context = new OfflineAudioContext(1, 1, 8000)
34
+ let audio: AudioBuffer
35
+ try { audio = await context.decodeAudioData(bytes) }
36
+ catch { signal.throwIfAborted(); throw new Error('waveform-invalid-audio') }
37
+ signal.throwIfAborted()
38
+ const decodedMs = audio.duration * 1000
39
+ if (!Number.isFinite(decodedMs) || decodedMs <= 0 || decodedMs > MAX_DURATION_MS) throw new Error('waveform-duration-limit')
40
+ // A materially different file must not draw a plausible waveform against the old plan's duration.
41
+ if (Math.abs(decodedMs - durationMs) > Math.max(100, durationMs * 0.01)) throw new Error('waveform-duration-changed')
42
+ const peaks = Array.from({ length: bins }, () => 0)
43
+ for (let channel = 0; channel < audio.numberOfChannels; channel++) {
44
+ const samples = audio.getChannelData(channel)
45
+ for (let index = 0; index < samples.length; index++) {
46
+ const bin = Math.min(bins - 1, Math.floor(index * bins / samples.length))
47
+ const sample = samples[index]!
48
+ if (!Number.isFinite(sample)) throw new Error('waveform-invalid-audio')
49
+ peaks[bin] = Math.max(peaks[bin]!, Math.min(1, Math.abs(sample)))
50
+ }
51
+ }
52
+ signal.throwIfAborted()
53
+ return Object.freeze({ durationMs: Math.round(decodedMs), peaks: Object.freeze(peaks) })
54
+ }
55
+
56
+ /** A visible production workspace owns this queue; closing it cancels reads and releases cached peaks. */
57
+ export class AudioWaveforms {
58
+ readonly #queue: Job[] = []
59
+ readonly #active = new Set<AbortController>()
60
+ readonly #cache = new Map<string, AudioWaveform>()
61
+ #closed = false
62
+
63
+ /** key includes the confirmed plan and clip identity; media is fetched only after a queue slot is available. */
64
+ read(key: string, load: Job['load'], durationMs: number, signal?: AbortSignal, bins = 128): Promise<AudioWaveform> {
65
+ if (this.#closed || signal?.aborted) return Promise.reject(signal?.reason ?? aborted())
66
+ if (!Number.isFinite(durationMs) || durationMs <= 0 || durationMs > MAX_DURATION_MS) return Promise.reject(new Error('waveform-duration-limit'))
67
+ if (!Number.isInteger(bins) || bins < 16 || bins > 2048) return Promise.reject(new Error('waveform-bin-limit'))
68
+ const identity = JSON.stringify([key, durationMs, bins]), cached = this.#cache.get(identity)
69
+ if (cached) {
70
+ this.#cache.delete(identity); this.#cache.set(identity, cached)
71
+ return Promise.resolve(cached)
72
+ }
73
+ if (this.#queue.length >= MAX_PENDING) return Promise.reject(new Error('waveform-queue-limit'))
74
+ return new Promise((resolve, reject) => {
75
+ const cancel = () => {
76
+ const index = this.#queue.indexOf(job)
77
+ if (index < 0) return
78
+ this.#queue.splice(index, 1); job.unlisten(); reject(signal?.reason ?? aborted())
79
+ }
80
+ const job: Job = { key: identity, load, durationMs, bins, signal, resolve, reject, unlisten: () => signal?.removeEventListener('abort', cancel) }
81
+ signal?.addEventListener('abort', cancel, { once: true })
82
+ this.#queue.push(job); this.#pump()
83
+ })
84
+ }
85
+
86
+ dispose(): void {
87
+ this.#closed = true
88
+ for (const job of this.#queue.splice(0)) { job.unlisten(); job.reject(aborted()) }
89
+ for (const controller of this.#active) controller.abort(aborted())
90
+ this.#cache.clear()
91
+ }
92
+
93
+ #pump(): void {
94
+ while (!this.#closed && this.#active.size < CONCURRENCY && this.#queue.length) {
95
+ const job = this.#queue.shift()!
96
+ job.unlisten()
97
+ const controller = new AbortController(), signal = job.signal ? AbortSignal.any([controller.signal, job.signal]) : controller.signal
98
+ this.#active.add(controller)
99
+ void (async () => {
100
+ signal.throwIfAborted()
101
+ const value = await decode(await job.load(signal), job.durationMs, job.bins, signal)
102
+ signal.throwIfAborted()
103
+ if (this.#closed) throw aborted()
104
+ this.#cache.delete(job.key); this.#cache.set(job.key, value)
105
+ if (this.#cache.size > MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value!)
106
+ return value
107
+ })().then(job.resolve, job.reject).finally(() => { this.#active.delete(controller); this.#pump() })
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,84 @@
1
+ import type { Workbench } from './workbench.ts'
2
+ import { speechFailureCode } from '@lincyaw/kernel'
3
+
4
+ export interface AuditionState { status: 'idle' | 'loading' | 'playing' | 'failed', error?: string }
5
+ export interface AuditionSource { audio: Blob | string }
6
+
7
+ /** An explicit audio audition owns its request and media; formal playback owns the lecture clock. */
8
+ export class Audition {
9
+ readonly #workbench: Workbench
10
+ readonly #listeners = new Set<() => void>()
11
+ readonly #unregister: () => void
12
+ #state: AuditionState = { status: 'idle' }
13
+ #version = 0
14
+ #request?: AbortController
15
+ #audio?: HTMLAudioElement
16
+ #objectUrl?: string
17
+ #disposed = false
18
+
19
+ constructor(workbench: Workbench) {
20
+ this.#workbench = workbench
21
+ this.#unregister = workbench.registerAudition(() => this.stop())
22
+ }
23
+
24
+ snapshot = (): AuditionState => this.#state
25
+ subscribe = (listener: () => void): (() => void) => { this.#listeners.add(listener); return () => { this.#listeners.delete(listener) } }
26
+ #set(state: AuditionState): void { this.#state = state; for (const listener of this.#listeners) listener() }
27
+
28
+ #release(): void {
29
+ this.#request?.abort()
30
+ this.#request = undefined
31
+ const audio = this.#audio
32
+ this.#audio = undefined
33
+ if (audio) { audio.onended = null; audio.onerror = null; audio.pause(); audio.removeAttribute('src'); audio.load() }
34
+ if (this.#objectUrl) URL.revokeObjectURL(this.#objectUrl)
35
+ this.#objectUrl = undefined
36
+ }
37
+
38
+ stop(): void {
39
+ this.#version++
40
+ this.#release()
41
+ if (!this.#disposed) this.#set({ status: 'idle' })
42
+ }
43
+
44
+ async play(produce: (signal: AbortSignal) => Promise<AuditionSource>): Promise<void> {
45
+ this.#workbench.stopAuditions()
46
+ if (this.#disposed) return
47
+ const version = this.#version
48
+ const request = new AbortController()
49
+ this.#request = request
50
+ const current = () => !this.#disposed && this.#version === version && !request.signal.aborted
51
+ this.#set({ status: 'loading' })
52
+ try {
53
+ await this.#workbench.pausePlayback(true)
54
+ if (!current()) return
55
+ const source = await produce(request.signal)
56
+ if (!current()) return
57
+ const audio = new Audio()
58
+ this.#audio = audio
59
+ audio.preload = 'auto'
60
+ audio.src = typeof source.audio === 'string' ? source.audio : this.#objectUrl = URL.createObjectURL(source.audio)
61
+ audio.onended = () => { if (current()) this.stop() }
62
+ audio.onerror = () => {
63
+ if (!current()) return
64
+ this.#release()
65
+ this.#set({ status: 'failed', error: 'audition-audio' })
66
+ }
67
+ await audio.play()
68
+ if (!current()) { audio.pause(); return }
69
+ this.#set({ status: 'playing' })
70
+ } catch (error) {
71
+ if (!current()) return
72
+ this.#release()
73
+ this.#set({ status: 'failed', error: speechFailureCode(error) ?? (error instanceof Error && error.message.startsWith('preview-playback-') ? error.message : 'audition-request') })
74
+ }
75
+ }
76
+
77
+ dispose(): void {
78
+ if (this.#disposed) return
79
+ this.stop()
80
+ this.#disposed = true
81
+ this.#unregister()
82
+ this.#listeners.clear()
83
+ }
84
+ }