@lincyaw/workbench 0.1.0 → 0.2.0-rc.1

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 (147) hide show
  1. package/README.md +40 -8
  2. package/package.json +27 -22
  3. package/src/MediaClipEditor.tsx +122 -0
  4. package/src/StepProductionEditor.tsx +130 -0
  5. package/src/approvals.tsx +60 -0
  6. package/src/assistant-mascot.ts +62 -0
  7. package/src/audio-recording-messages.ts +44 -0
  8. package/src/audio-recording.ts +192 -0
  9. package/src/audio-wav.ts +25 -0
  10. package/src/audio-waveform.ts +110 -0
  11. package/src/audition.ts +84 -0
  12. package/src/authoring-client.ts +136 -0
  13. package/src/authoring-draft-fragment.ts +28 -0
  14. package/src/base-controls.tsx +47 -0
  15. package/src/caption-timing-drafts.ts +61 -0
  16. package/src/caption-timing-messages.ts +14 -0
  17. package/src/caption-timing.css +20 -0
  18. package/src/caption-timing.ts +14 -0
  19. package/src/component-draft-actions.ts +54 -0
  20. package/src/component-draft-inspection.ts +35 -0
  21. package/src/component-properties.css +24 -0
  22. package/src/component-properties.ts +45 -0
  23. package/src/component-property-drafts.ts +273 -0
  24. package/src/component-property-messages.ts +31 -0
  25. package/src/components.tsx +547 -90
  26. package/src/context.tsx +27 -0
  27. package/src/diagnostics.ts +113 -0
  28. package/src/document-draft-store.ts +179 -0
  29. package/src/document-drafts.ts +178 -0
  30. package/src/document-editor/BlockNoteSurface.tsx +209 -0
  31. package/src/document-editor/ComponentMaterials.tsx +62 -0
  32. package/src/document-editor/ComponentPropertiesDialog.tsx +282 -0
  33. package/src/document-editor/DocumentBlockFallback.tsx +31 -0
  34. package/src/document-editor/DocumentBlockPreview.tsx +239 -0
  35. package/src/document-editor/DocumentComments.tsx +282 -0
  36. package/src/document-editor/DocumentEditor.tsx +192 -0
  37. package/src/document-editor/DocumentStepNarration.tsx +93 -0
  38. package/src/document-editor/NarrationEditor.tsx +110 -0
  39. package/src/document-editor/NewCommentDrafts.tsx +224 -0
  40. package/src/document-editor/authoring.tsx +259 -0
  41. package/src/document-editor/block-playback.ts +90 -0
  42. package/src/document-editor/block-preview-messages.ts +13 -0
  43. package/src/document-editor/blocknote-messages.ts +98 -0
  44. package/src/document-editor/clipboard.ts +40 -0
  45. package/src/document-editor/colors.ts +57 -0
  46. package/src/document-editor/comment-draft-actions.ts +58 -0
  47. package/src/document-editor/comment-draft-inspection.ts +38 -0
  48. package/src/document-editor/comment-drafts.ts +361 -0
  49. package/src/document-editor/comments.ts +410 -0
  50. package/src/document-editor/file-input.ts +71 -0
  51. package/src/document-editor/focus.ts +54 -0
  52. package/src/document-editor/inline.ts +104 -0
  53. package/src/document-editor/math.tsx +63 -0
  54. package/src/document-editor/messages.ts +14 -0
  55. package/src/document-editor/narration-schema.tsx +85 -0
  56. package/src/document-editor/narration.ts +37 -0
  57. package/src/document-editor/popover.tsx +107 -0
  58. package/src/document-editor/projection.ts +423 -0
  59. package/src/document-editor/rich-text.ts +152 -0
  60. package/src/document-editor/schema.tsx +117 -0
  61. package/src/document-editor/selection.ts +152 -0
  62. package/src/document-editor/style.css +9 -0
  63. package/src/document-editor/styles.d.ts +1 -0
  64. package/src/document-editor/table-identities.ts +67 -0
  65. package/src/document-preview.ts +112 -0
  66. package/src/document-return-point.ts +59 -0
  67. package/src/document-session.ts +678 -0
  68. package/src/document-timing-drafts.ts +61 -0
  69. package/src/document-voices.ts +2 -0
  70. package/src/history.ts +167 -0
  71. package/src/index.ts +33 -43
  72. package/src/jobs.ts +111 -0
  73. package/src/mascot-preference.ts +37 -0
  74. package/src/media-drafts.ts +83 -0
  75. package/src/media-messages.ts +53 -0
  76. package/src/media-time.ts +12 -0
  77. package/src/messages.ts +751 -203
  78. package/src/outline.ts +97 -0
  79. package/src/panels/ChangeTrail.tsx +79 -0
  80. package/src/panels/DocumentNarrationPanel.tsx +27 -0
  81. package/src/panels/DocumentView.tsx +456 -44
  82. package/src/panels/DocumentVoiceDialog.tsx +179 -0
  83. package/src/panels/DocumentVoiceField.tsx +35 -0
  84. package/src/panels/ExportPanel.tsx +133 -0
  85. package/src/panels/HistoryPanel.tsx +88 -0
  86. package/src/panels/HomePanel.tsx +184 -0
  87. package/src/panels/PageDeleteDialog.tsx +33 -0
  88. package/src/panels/PageRenameDialog.tsx +27 -0
  89. package/src/panels/PreviewPanel.tsx +79 -0
  90. package/src/panels/ProductionCaptionTiming.tsx +122 -0
  91. package/src/panels/ProductionMediaClips.tsx +156 -0
  92. package/src/panels/ProductionPanel.tsx +272 -0
  93. package/src/panels/ProductionRecording.tsx +107 -0
  94. package/src/panels/ProductionRecordingRange.tsx +118 -0
  95. package/src/panels/ProductionTimeline.tsx +62 -0
  96. package/src/panels/ProductionTranscription.tsx +140 -0
  97. package/src/panels/SourceDocumentPanel.tsx +215 -0
  98. package/src/panels/SpeechSettingsPanel.tsx +64 -0
  99. package/src/panels/TranscriptionAdoption.tsx +147 -0
  100. package/src/panels/VoiceCloneDialog.tsx +152 -0
  101. package/src/panels/VoicePreviewDialog.tsx +74 -0
  102. package/src/panels/VoiceScopeField.tsx +30 -0
  103. package/src/panels/VoiceoverDialog.tsx +216 -0
  104. package/src/places.ts +74 -6
  105. package/src/preview-playback.ts +125 -0
  106. package/src/preview-pool.ts +320 -0
  107. package/src/preview.ts +57 -6
  108. package/src/production-messages.ts +151 -0
  109. package/src/production-playback.ts +109 -0
  110. package/src/production-voice-playback.ts +114 -0
  111. package/src/recording-adoption.ts +33 -0
  112. package/src/schema-form/PmSchemaForm.tsx +187 -0
  113. package/src/schema-form/context.ts +26 -0
  114. package/src/schema-form/fields.tsx +59 -0
  115. package/src/schema-form/templates.tsx +50 -0
  116. package/src/schema-form/validator.ts +36 -0
  117. package/src/schema-form/widgets.tsx +60 -0
  118. package/src/shortcuts.ts +8 -0
  119. package/src/sortable-list.tsx +201 -0
  120. package/src/speech-settings-messages.ts +40 -0
  121. package/src/step-production.css +5 -0
  122. package/src/step-production.ts +51 -0
  123. package/src/styles.css +757 -361
  124. package/src/telemetry.ts +152 -0
  125. package/src/transcription-messages.ts +26 -0
  126. package/src/transcription-session.ts +159 -0
  127. package/src/use-recording-adoption.ts +84 -0
  128. package/src/voice-clone-messages.ts +92 -0
  129. package/src/voice-drafts.ts +89 -0
  130. package/src/voice-messages.ts +146 -0
  131. package/src/voice-snapshot.ts +55 -0
  132. package/src/voiceover-jobs.ts +87 -0
  133. package/src/workbench.ts +2328 -0
  134. package/src/workspace-client.ts +480 -0
  135. package/src/api.ts +0 -126
  136. package/src/editor.ts +0 -223
  137. package/src/merge.ts +0 -30
  138. package/src/panels/Changes.tsx +0 -128
  139. package/src/panels/Files.tsx +0 -73
  140. package/src/panels/Home.tsx +0 -224
  141. package/src/panels/Preview.tsx +0 -68
  142. package/src/panels/Problems.tsx +0 -42
  143. package/src/panels/Source.tsx +0 -118
  144. package/src/panels/Status.tsx +0 -26
  145. package/src/react.tsx +0 -59
  146. package/src/service.ts +0 -82
  147. package/src/store.ts +0 -599
@@ -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
+ }
@@ -0,0 +1,136 @@
1
+ import { authoringClientResultFits } from '@lincyaw/kernel'
2
+ import type { AuthoringClientActionResult, AuthoringClientEvent, AuthoringClientSnapshot, CanonicalSource } from '@lincyaw/kernel'
3
+ import type { Workbench } from './workbench.ts'
4
+ import type { AuthoringClientChannel } from './workspace-client.ts'
5
+
6
+ const sameSource = (a: CanonicalSource, b: CanonicalSource) => a.documentId === b.documentId && a.version === b.version && a.fingerprint === b.fingerprint
7
+ // Clock samples are observations, not user input revisions. Never push at animation-frame frequency.
8
+ const signature = (value: AuthoringClientSnapshot) => JSON.stringify({ ...value, revision: 0, playback: value.playback && { ...value.playback,
9
+ position: value.playback.position && { ...value.playback.position, timeMs: 0, stepTimeMs: 0 } } })
10
+ type Attachment = { directory: string, identity: string, epoch: string, channel: AuthoringClientChannel, connectionId?: string }
11
+
12
+ /** One logical reader on Studio's shared authenticated socket; no extra browser SSE or polling timer. */
13
+ export function connectAuthoringClient(workbench: Workbench): () => void {
14
+ const open = workbench.client.openAuthoringClient
15
+ if (!open) return () => {}
16
+ const clientId = crypto.randomUUID()
17
+ let closed = false, revision = 0, sequence = 0
18
+ let attached: Attachment | undefined
19
+ let lastSignature: string | undefined, lastContent: object | undefined
20
+ let lastDraftRevision: string | undefined
21
+ let active: { requestId: string, controller: AbortController } | undefined
22
+ let reports: Promise<void> = Promise.resolve()
23
+ let pendingState: { owner: Attachment, connectionId: string, value: AuthoringClientSnapshot } | undefined
24
+ let stateSending = false
25
+ const seen = new Set<string>()
26
+ const snapshot = (): AuthoringClientSnapshot | undefined => {
27
+ const value = workbench.authoringClientSnapshot(revision)
28
+ if (!value) return
29
+ const next = signature(value), content = workbench.state.authoring?.content, drafts = workbench.authoringDraftRevision()
30
+ if (next !== lastSignature || content !== lastContent || drafts !== lastDraftRevision) { revision++; lastSignature = next; lastContent = content; lastDraftRevision = drafts }
31
+ return { ...value, revision }
32
+ }
33
+ const detach = () => {
34
+ active?.controller.abort(); active = undefined
35
+ attached?.channel.close(); attached = undefined
36
+ pendingState = undefined
37
+ lastSignature = undefined; lastContent = undefined; lastDraftRevision = undefined; seen.clear()
38
+ }
39
+ const send = (owner: Attachment, connectionId: string, value: AuthoringClientSnapshot, acknowledgement?: { requestId: string, state: 'executing' | 'done' | 'refused' | 'failed', error?: { code: string }, result?: AuthoringClientActionResult }, preflight?: () => void) => {
40
+ const report = { sequence: ++sequence, snapshot: value, ...acknowledgement }
41
+ const next = reports.then(() => {
42
+ if (closed || attached !== owner || owner.connectionId !== connectionId) throw new Error('client-connection-changed')
43
+ preflight?.()
44
+ return acknowledgement ? owner.channel.acknowledge(connectionId, report as import('@lincyaw/kernel').AuthoringClientAcknowledgement) : owner.channel.publish(connectionId, report)
45
+ })
46
+ reports = next.catch(() => {})
47
+ return next
48
+ }
49
+ const publishState = (owner: Attachment, connectionId: string, value: AuthoringClientSnapshot) => {
50
+ pendingState = { owner, connectionId, value }
51
+ if (stateSending) return
52
+ stateSending = true
53
+ void (async () => {
54
+ try {
55
+ while (pendingState) {
56
+ const pending = pendingState; pendingState = undefined
57
+ if (attached !== pending.owner || attached.connectionId !== pending.connectionId) continue
58
+ // Coalesce rapid input while a publication is in flight; never retain an unbounded keystroke queue.
59
+ const latest = snapshot() ?? pending.value
60
+ await send(pending.owner, pending.connectionId, latest).catch(() => {})
61
+ }
62
+ } finally { stateSending = false }
63
+ })()
64
+ }
65
+ const received = async (owner: Attachment, event: AuthoringClientEvent) => {
66
+ if (closed || attached !== owner) return
67
+ if (event.type === 'connected') {
68
+ active?.controller.abort(); active = undefined
69
+ owner.connectionId = event.connectionId
70
+ const value = snapshot()
71
+ if (value) publishState(owner, event.connectionId, value)
72
+ return
73
+ }
74
+ if (event.type === 'cancel') { if (event.connectionId === owner.connectionId && event.requestId === active?.requestId) active.controller.abort(); return }
75
+ const request = event.request, connectionId = owner.connectionId
76
+ if (!connectionId || request.clientId !== clientId || request.epoch !== owner.epoch || request.expectedClientState.connectionId !== connectionId || seen.has(request.requestId)) return
77
+ seen.add(request.requestId)
78
+ if (seen.size > 64) seen.delete(seen.values().next().value!)
79
+ const value = snapshot()
80
+ if (!value) return
81
+ const controller = new AbortController(), task = { requestId: request.requestId, controller }
82
+ const owned = () => {
83
+ controller.signal.throwIfAborted()
84
+ if (closed || attached !== owner || owner.connectionId !== connectionId || active !== task || Date.now() >= Date.parse(event.expiresAt)) throw new Error('client-request-expired')
85
+ }
86
+ if (active || value.revision !== request.expectedClientState.revision || !sameSource(value.source, request.source) || !value.actions.includes(request.action.type) || Date.now() >= Date.parse(event.expiresAt)) {
87
+ await send(owner, connectionId, value, { requestId: request.requestId, state: 'refused', error: { code: 'client-state-changed' } }).catch(() => {})
88
+ return
89
+ }
90
+ active = task
91
+ const expiresAt = Date.parse(event.expiresAt)
92
+ const timeout = setTimeout(() => controller.abort(new Error('client-request-expired')), Math.max(0, expiresAt - Date.now()))
93
+ try {
94
+ // Only a confirmed executing acknowledgement grants this epoch permission to invoke a control.
95
+ await send(owner, connectionId, value, { requestId: request.requestId, state: 'executing' })
96
+ owned()
97
+ const latest = snapshot()
98
+ if (!latest || latest.revision !== request.expectedClientState.revision || !sameSource(latest.source, request.source)) throw new Error('client-state-changed')
99
+ const fits = (result: AuthoringClientActionResult) => authoringClientResultFits({ request, connection: { clientId, epoch: owner.epoch, connectionId }, document: owner.directory, snapshot: snapshot() ?? value }, result)
100
+ const actionResult = await workbench.executeAuthoringClientAction(request.action, request.source, controller.signal, expiresAt, fits)
101
+ owned()
102
+ const result = snapshot()
103
+ if (!result) throw new Error('client-document-changed')
104
+ await send(owner, connectionId, result, { requestId: request.requestId, state: 'done', ...actionResult ? { result: actionResult } : {} }, actionResult ? () => {
105
+ owned()
106
+ const latest = snapshot()
107
+ if (!latest || latest.revision !== result.revision || !['component-drafts.restore', 'component-drafts.discard', 'comment-drafts.restore', 'comment-drafts.discard'].includes(actionResult.type) && latest.revision !== request.expectedClientState.revision) throw new Error(actionResult.family === 'component' ? 'client-component-drafts-changed' : 'client-comment-drafts-changed')
108
+ if (!fits(actionResult)) throw new Error(actionResult.family === 'component' ? 'client-component-result-too-large' : 'client-comment-result-too-large')
109
+ } : undefined)
110
+ } catch (error) {
111
+ const result = snapshot()
112
+ const code = error instanceof Error && /^(client|production)-[a-z-]{1,100}$/.test(error.message) ? error.message : 'client-action-not-verified'
113
+ if (!controller.signal.aborted && attached === owner && owner.connectionId === connectionId && result) await send(owner, connectionId, result, { requestId: request.requestId, state: 'failed', error: { code } }).catch(() => {})
114
+ } finally { clearTimeout(timeout); if (active === task) active = undefined }
115
+ }
116
+ const changed = () => {
117
+ const state = workbench.state, source = state.authoring
118
+ if (closed || state.document === undefined || !source?.identity || source.loading) { detach(); return }
119
+ const before = revision, value = snapshot()
120
+ if (!value) { detach(); return }
121
+ if (attached && (attached.directory !== state.document || attached.identity !== source.identity)) detach()
122
+ if (!attached) {
123
+ const epoch = crypto.randomUUID()
124
+ // Callbacks run after the channel has been installed; the socket implementation is asynchronous.
125
+ const owner = { directory: state.document, identity: source.identity, epoch } as Attachment
126
+ owner.channel = open(state.document, { clientId, epoch, snapshot: value }, event => { void received(owner, event) }, () => {
127
+ if (attached === owner) { owner.connectionId = undefined; active?.controller.abort(); active = undefined }
128
+ })
129
+ attached = owner
130
+ } else if (before !== revision && attached.connectionId) publishState(attached, attached.connectionId, value)
131
+ }
132
+ const stop = workbench.subscribe(changed)
133
+ const stopDrafts = workbench.subscribeAuthoringDraftChanges(changed)
134
+ changed()
135
+ return () => { closed = true; stop(); stopDrafts(); detach() }
136
+ }
@@ -0,0 +1,28 @@
1
+ import type { AuthoringClientActionResult } from '@lincyaw/kernel'
2
+
3
+ type Fragment = Extract<AuthoringClientActionResult, { jsonText: string }>
4
+ type Fields = 'offset' | 'endOffset' | 'totalUtf16' | 'totalUtf8' | 'sha256' | 'jsonText'
5
+ type FragmentBase = Fragment extends infer Value ? Value extends Fragment ? Omit<Value, Fields> : never : never
6
+ export const draftContentHash = async (text: string): Promise<string> => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))].map(value => value.toString(16).padStart(2, '0')).join('')
7
+ const boundary = (text: string, offset: number) => !(offset > 0 && offset < text.length && /[\uD800-\uDBFF]/.test(text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(text[offset]!))
8
+
9
+ /** Budget the complete client receipt and WS frame, preserving exact JSON text and Unicode progress. */
10
+ export async function authoringDraftFragment(options: { text: string, base: FragmentBase, offset: number, maxChars?: number }, current: () => void, fits: (value: AuthoringClientActionResult) => boolean): Promise<Fragment> {
11
+ const { text, base, offset } = options, maxChars = options.maxChars ?? 2048
12
+ const sha256 = await draftContentHash(text)
13
+ current()
14
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length || !boundary(text, offset)) throw new Error(`client-${base.family}-offset-invalid`)
15
+ if (!Number.isSafeInteger(maxChars) || maxChars < 2 || maxChars > 2048) throw new Error(`client-${base.family}-fragment-limit`)
16
+ const lengths = { offset, totalUtf16: text.length, totalUtf8: new TextEncoder().encode(text).byteLength, sha256 }
17
+ let low = offset, high = Math.min(text.length, offset + maxChars), best: Fragment | undefined
18
+ while (low <= high) {
19
+ const middle = Math.floor((low + high) / 2)
20
+ const end = boundary(text, middle) ? middle : middle - 1
21
+ const result: Fragment = { ...base, ...lengths, endOffset: end, jsonText: text.slice(offset, end) }
22
+ if (fits(result)) { if (end > offset || end === text.length) best = result; low = middle + 1 }
23
+ else high = middle - 1
24
+ }
25
+ if (!best) throw new Error(`client-${base.family}-fragment-too-large`)
26
+ current()
27
+ return best
28
+ }
@@ -0,0 +1,47 @@
1
+ import { ICONS } from '@lincyaw/design'
2
+ import type { IconName } from '@lincyaw/design'
3
+ import * as Tooltip from '@radix-ui/react-tooltip'
4
+ import { createElement, forwardRef } from 'react'
5
+ import type { ButtonHTMLAttributes, ReactNode } from 'react'
6
+
7
+ const cls = (...names: (string | false | undefined | null)[]) => names.filter(Boolean).join(' ')
8
+
9
+ export function PmIcon({ name, size: _size, strokeWidth = 1.7 }: { name: IconName, size?: number | string, strokeWidth?: number }) {
10
+ return (
11
+ <svg className="pm-ui-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
12
+ {ICONS[name].map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
13
+ </svg>
14
+ )
15
+ }
16
+
17
+ export type ButtonVariant = 'default' | 'primary' | 'accent' | 'ghost' | 'danger'
18
+
19
+ export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & { type?: 'button' | 'submit' | 'reset' }
20
+ export type ControlDensity = 'compact' | 'regular' | 'touch'
21
+ export type ControlProps = { density?: ControlDensity, loading?: boolean }
22
+
23
+ export const PmButton = forwardRef<HTMLButtonElement, ButtonProps & ControlProps & { variant?: ButtonVariant, small?: boolean, icon?: IconName }>(function PmButton({ variant = 'default', small, density = small ? 'compact' : 'regular', loading, icon, type = 'button', className, children, disabled, ...rest }, ref) {
24
+ return (
25
+ <button {...rest} ref={ref} type={type} data-density={density} aria-busy={loading || undefined} disabled={disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true'} className={cls('pm-ui-button', variant !== 'default' && `pm-ui-button--${variant}`, className)}>
26
+ <span className={cls('pm-ui-button__content', !icon && loading !== undefined && 'pm-ui-button__content--loading-text')}>{(icon || loading !== undefined) && <span aria-hidden="true" className={cls('pm-ui-button__icon', loading && 'pm-ui-loading')}>{loading ? <PmIcon name="loading" /> : icon && <PmIcon name={icon} />}</span>}{children}</span>
27
+ </button>
28
+ )
29
+ })
30
+
31
+ /** Radix opens on keyboard focus immediately; pointer hover uses the I11 delay. */
32
+ export function PmTooltip({ label, shortcut, children }: { label: string, shortcut?: string, children: ReactNode }) {
33
+ return (
34
+ <Tooltip.Provider delayDuration={300} skipDelayDuration={0}>
35
+ <Tooltip.Root><Tooltip.Trigger asChild>{children}</Tooltip.Trigger><Tooltip.Portal><div className="pm-ui-tooltip-layer"><Tooltip.Content className="pm-ui-tooltip pm-ui" side="bottom" sideOffset={6} collisionPadding={8}>
36
+ {label}{shortcut && <kbd className="pm-ui-shortcut">{shortcut}</kbd>}
37
+ </Tooltip.Content></div></Tooltip.Portal></Tooltip.Root>
38
+ </Tooltip.Provider>
39
+ )
40
+ }
41
+
42
+ export const PmIconButton = forwardRef<HTMLButtonElement, ButtonProps & ControlProps & { icon: IconName, label: string, hint?: string, shortcut?: string, small?: boolean, tooltip?: boolean }>(function PmIconButton({ icon, label, hint, shortcut, small, tooltip = true, density = small ? 'compact' : 'regular', loading, className, disabled, ...rest }, ref) {
43
+ const button = <button {...rest} ref={ref} type="button" data-density={density} aria-label={label} aria-busy={loading || undefined} disabled={disabled || loading || rest['aria-disabled'] === true || rest['aria-disabled'] === 'true'} className={cls('pm-ui-icon-button', className)}>
44
+ <span className={cls('pm-ui-button__content', loading && 'pm-ui-loading')}><PmIcon name={loading ? 'loading' : icon} /></span>
45
+ </button>
46
+ return tooltip ? <PmTooltip label={hint ?? label} shortcut={shortcut}>{button}</PmTooltip> : button
47
+ })