@lincyaw/workbench 0.1.0 → 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 (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,152 @@
1
+ /** A value of an operation's attribute; undefined ones are left out. */
2
+ export type OperationValue = string | number | boolean | undefined
3
+
4
+ /** A batch of the author's operations, as the workbench sends it to the host. */
5
+ export interface OperationBatch {
6
+ client: { id: string, browser?: string, locale?: string }
7
+ records: { name: string, time: number, seq: number, attributes: Record<string, string | number | boolean> }[]
8
+ }
9
+
10
+ const FLUSH_MS = 2000
11
+ const BATCH = 200
12
+ // Operations kept while the host has not answered, or while it cannot be reached.
13
+ const QUEUE = 5000
14
+ const BROWSER_KEY = 'presmium.telemetry.browser'
15
+ const CONTROLS = 'button, a[href], [role="button"], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="switch"], input[type="checkbox"], input[type="radio"], select, summary'
16
+
17
+ function newId(): string {
18
+ return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
19
+ }
20
+
21
+ /** An id of this browser, kept in its storage, so that the loads of the workbench in it can be told apart from another's. */
22
+ function browserId(): string | undefined {
23
+ try {
24
+ const known = localStorage.getItem(BROWSER_KEY)
25
+ if (known) return known
26
+ const id = newId()
27
+ localStorage.setItem(BROWSER_KEY, id)
28
+ return id
29
+ } catch {
30
+ return undefined
31
+ }
32
+ }
33
+
34
+ /** The name a control shows the author: its label, else its text. */
35
+ function controlName(control: Element): string {
36
+ const label = control.getAttribute('aria-label') ?? control.getAttribute('title')
37
+ return (label ?? (control as HTMLElement).innerText ?? control.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120)
38
+ }
39
+
40
+ /**
41
+ * The author's operations in the workbench, for the operator's collector: kept in order with their time, and sent in
42
+ * batches to the host (`/api/presmium/telemetry`), which exports them with each session's events. Nothing leaves the
43
+ * page unless the host says it collects them; until it answers, operations wait.
44
+ */
45
+ export class OperationLog {
46
+ readonly #read: () => Promise<{ enabled: boolean }>
47
+ readonly #send: (batch: OperationBatch, beacon: boolean) => Promise<void>
48
+ readonly #client: OperationBatch['client']
49
+ #enabled: boolean | undefined
50
+ #queue: OperationBatch['records'] = []
51
+ #seq = 0
52
+ #timer: ReturnType<typeof setTimeout> | undefined
53
+ #sending = false
54
+ #stop: (() => void) | undefined
55
+
56
+ constructor(read: () => Promise<{ enabled: boolean }>, send: (batch: OperationBatch, beacon: boolean) => Promise<void>, locale?: string) {
57
+ this.#read = read
58
+ this.#send = send
59
+ const browser = browserId()
60
+ this.#client = { id: newId(), ...browser ? { browser } : {}, ...locale ? { locale } : {} }
61
+ }
62
+
63
+ /** The id of this load of the workbench, carried by each of its operations. */
64
+ get client(): string {
65
+ return this.#client.id
66
+ }
67
+
68
+ /** Asks the host whether it collects operations; when it does, sends them, and also records clicks on the page's controls. */
69
+ async start(): Promise<void> {
70
+ try {
71
+ this.#enabled = (await this.#read()).enabled === true
72
+ } catch {
73
+ this.#enabled = false
74
+ }
75
+ if (!this.#enabled) {
76
+ this.#queue = []
77
+ return
78
+ }
79
+ const hidden = () => {
80
+ if (document.visibilityState === 'hidden') this.flush(true)
81
+ }
82
+ const away = () => this.flush(true)
83
+ const clicked = (event: MouseEvent) => {
84
+ const control = event.target instanceof Element ? event.target.closest(CONTROLS) : null
85
+ if (!control) return
86
+ const region = control.closest('[aria-label][role="region"], [data-pmw-slot], [role="dialog"]')
87
+ this.record('ui.click', {
88
+ 'presmium.control': controlName(control),
89
+ 'presmium.control.role': control.getAttribute('role') ?? control.tagName.toLowerCase(),
90
+ 'presmium.control.activity': control.getAttribute('data-activity') ?? undefined,
91
+ 'presmium.region': region ? (region.getAttribute('aria-label') ?? region.getAttribute('data-pmw-slot') ?? undefined) : undefined,
92
+ })
93
+ }
94
+ document.addEventListener('visibilitychange', hidden)
95
+ window.addEventListener('pagehide', away)
96
+ document.addEventListener('click', clicked, true)
97
+ this.#stop = () => {
98
+ document.removeEventListener('visibilitychange', hidden)
99
+ window.removeEventListener('pagehide', away)
100
+ document.removeEventListener('click', clicked, true)
101
+ }
102
+ this.#schedule()
103
+ }
104
+
105
+ /** Records one operation now; `attributes` with no value are left out. */
106
+ record(name: string, attributes: Record<string, OperationValue> = {}): void {
107
+ if (this.#enabled === false) return
108
+ const kept: Record<string, string | number | boolean> = {}
109
+ for (const [key, value] of Object.entries(attributes)) if (value !== undefined) kept[key] = value
110
+ this.#queue.push({ name, time: Date.now(), seq: ++this.#seq, attributes: kept })
111
+ if (this.#queue.length > QUEUE) this.#queue.splice(0, this.#queue.length - QUEUE)
112
+ if (this.#enabled === undefined) return
113
+ if (this.#queue.length >= BATCH) this.flush()
114
+ else this.#schedule()
115
+ }
116
+
117
+ /** Sends what is queued; `beacon` when the page goes away, without waiting for an answer. */
118
+ flush(beacon = false): void {
119
+ clearTimeout(this.#timer)
120
+ this.#timer = undefined
121
+ if (!this.#enabled || this.#queue.length === 0 || (this.#sending && !beacon)) return
122
+ const records = this.#queue.splice(0, beacon ? this.#queue.length : BATCH)
123
+ const batch = { client: this.#client, records }
124
+ if (beacon) {
125
+ void this.#send(batch, true).catch(() => undefined)
126
+ return
127
+ }
128
+ this.#sending = true
129
+ this.#send(batch, false).then(() => {
130
+ this.#sending = false
131
+ if (this.#queue.length > 0) this.#schedule()
132
+ }, () => {
133
+ // Kept for the next try, in their order, before what came since.
134
+ this.#sending = false
135
+ this.#queue.unshift(...records)
136
+ if (this.#queue.length > QUEUE) this.#queue.splice(0, this.#queue.length - QUEUE)
137
+ this.#schedule()
138
+ })
139
+ }
140
+
141
+ #schedule(): void {
142
+ if (this.#timer === undefined && this.#queue.length > 0) this.#timer = setTimeout(() => this.flush(), FLUSH_MS)
143
+ }
144
+
145
+ /** Sends what is left and stops recording clicks. */
146
+ stop(): void {
147
+ this.flush(true)
148
+ this.#stop?.()
149
+ this.#stop = undefined
150
+ this.#enabled = false
151
+ }
152
+ }
@@ -0,0 +1,26 @@
1
+ import type { Locale } from '@lincyaw/kernel'
2
+
3
+ export const TRANSCRIPTION_MESSAGES = {
4
+ 'zh-CN': {
5
+ import: '导入音频', retry: '重试', records: '转写记录', partial: '部分转写记录无法读取。', other: '另有转写正在进行。', show: '查看', audioUnavailable: '暂时无法检查音频。', loading: '正在加载…',
6
+ title: '转写', audio: '音频', service: '转写服务', none: '请选择音频', noAudio: '先录音或导入音频。', unavailable: '尚未配置转写服务。', unresolved: '暂时无法确认转写服务。', refresh: '刷新转写状态', start: '转写', cancel: '取消转写', cancelTitle: '取消转写?', cancelKeep: '继续转写', cancelConfirm: '取消', close: '关闭',
7
+ sending: '等待服务确认', queued: '等待转写', running: '正在转写', cancelling: '正在取消', cancelled: '已取消', done: '转写完成', failed: '转写失败', stale: '来源已更新', requestFailed: '无法确认转写结果,请刷新或取消。', readFailed: '无法读取转写结果,请重试。', storage: '本地恢复不可用,请保留此页面。',
8
+ text: '转写文本', review: '对照并采用为讲稿', before: '现有讲稿', after: '采用后', apply: '采用讲稿', applying: '正在保存…', adopted: '讲稿已保存', narration: '讲稿', newNarration: '新建讲稿', segments: '采用段落', allSegments: '全部段落', segment: (index: number) => `第 ${index + 1} 段`, empty: '没有可采用的文字。',
9
+ atoms: '发音与提示', keepAtom: '保留到光标处', removeAtom: '移除', pronunciation: '发音', cue: '提示', resolveAtoms: '请核对发音与提示的位置。', unknownAtom: '发音或提示已变化,请重新核对。', removed: '确认移除', removeTiming: '同时移除对应停顿', shared: '共享讲稿', fork: '仅当前步骤', editShared: '修改所有引用步骤', affected: (count: number) => `将修改 ${count} 个步骤`, conflicts: '还有未核对的修改。', sourceChanged: '文稿已更新,请重新对照。', confirm: '对照修改', edit: '继续校对', previewFailed: '暂时无法对照,请重试。', applyFailed: '无法确认已保存,校对内容已保留。', draftStorage: '校对内容暂时无法恢复,请保留此页面。', stepChanged: '目标步骤已变化,请重新选择。',
10
+ },
11
+ en: {
12
+ import: 'Import Audio', retry: 'Retry', records: 'Transcription history', partial: 'Some transcription records could not be read.', other: 'Another transcription is running.', show: 'View', audioUnavailable: 'Couldn’t check the audio right now.', loading: 'Loading…',
13
+ title: 'Transcription', audio: 'Audio', service: 'Transcription service', none: 'Select audio', noAudio: 'Record or import audio first.', unavailable: 'No transcription service configured.', unresolved: 'Couldn’t confirm the transcription service.', refresh: 'Refresh Transcription', start: 'Transcribe', cancel: 'Cancel Transcription', cancelTitle: 'Cancel transcription?', cancelKeep: 'Keep Transcribing', cancelConfirm: 'Cancel', close: 'Close',
14
+ sending: 'Waiting for the service', queued: 'Queued', running: 'Transcribing', cancelling: 'Canceling', cancelled: 'Canceled', done: 'Transcription completed', failed: 'Transcription failed', stale: 'Source changed', requestFailed: 'Couldn’t confirm the result. Refresh or cancel.', readFailed: 'Couldn’t read the transcript. Try again.', storage: 'Local recovery is unavailable. Keep this page open.',
15
+ text: 'Transcript', review: 'Compare and Use as Script', before: 'Current script', after: 'Proposed script', apply: 'Use Script', applying: 'Saving…', adopted: 'Script saved', narration: 'Script', newNarration: 'New Script', segments: 'Transcript segments', allSegments: 'All segments', segment: (index: number) => `Segment ${index + 1}`, empty: 'No text to adopt.',
16
+ atoms: 'Pronunciations and cues', keepAtom: 'Keep at Cursor', removeAtom: 'Remove', pronunciation: 'Pronunciation', cue: 'Cue', resolveAtoms: 'Review pronunciation and cue positions.', unknownAtom: 'Pronunciations or cues changed. Review them again.', removed: 'Confirm Removal', removeTiming: 'Also remove its pause', shared: 'Shared script', fork: 'This step only', editShared: 'All referencing steps', affected: (count: number) => `${count} steps will change`, conflicts: 'Some changes still need review.', sourceChanged: 'The document changed. Compare again.', confirm: 'Compare Changes', edit: 'Keep Editing', previewFailed: 'Couldn’t prepare the comparison. Try again.', applyFailed: 'Couldn’t confirm the save. Your edits are kept.', draftStorage: 'Draft recovery is unavailable. Keep this page open.', stepChanged: 'The target step changed. Select it again.',
17
+ },
18
+ } satisfies Record<Locale, object>
19
+
20
+ export function transcriptionFailure(error: unknown, locale: Locale): string {
21
+ const t = TRANSCRIPTION_MESSAGES[locale], code = error instanceof Error ? error.message : ''
22
+ if (code.includes('version-changed') || code.includes('source-changed') || code.includes('plan-changed') || code.includes('plan-expired')) return t.sourceChanged
23
+ if (code.includes('engine-unavailable') || code.includes('engine-unsupported') || code.includes('engine-unknown') || code.includes('speech-unavailable')) return t.unavailable
24
+ if (code.includes('services-unavailable')) return t.unresolved
25
+ return t.requestFailed
26
+ }
@@ -0,0 +1,159 @@
1
+ import { isRecord } from '@lincyaw/kernel'
2
+ import type { DocumentTranscriptionRequest, TranscriptionTask } from '@lincyaw/kernel'
3
+ import { WorkspaceServerError } from './workspace-client.ts'
4
+ import type { WorkspaceClient } from './workspace-client.ts'
5
+
6
+ export interface TranscriptionSessionState {
7
+ request?: DocumentTranscriptionRequest
8
+ task?: TranscriptionTask
9
+ cancelled?: boolean
10
+ sending: boolean
11
+ cancelling: boolean
12
+ checking: boolean
13
+ verified: boolean
14
+ failure?: string
15
+ storageFailure: boolean
16
+ }
17
+ const active = (task?: TranscriptionTask) => Boolean(task && ['queued', 'running', 'cancelling'].includes(task.state))
18
+ const validIntent = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z\d_-]{16,128}$/.test(value)
19
+ const recoveredRequest = (value: unknown, documentId: string): value is DocumentTranscriptionRequest => isRecord(value) && isRecord(value.source)
20
+ && value.source.documentId === documentId && typeof value.source.version === 'string' && /^[a-f\d]{20}$/.test(value.source.version)
21
+ && typeof value.source.fingerprint === 'string' && /^[a-f\d]{16}$/.test(value.source.fingerprint) && validIntent(value.intent)
22
+ && typeof value.assetId === 'string' && Boolean(value.assetId) && typeof value.engine === 'string' && Boolean(value.engine)
23
+ && typeof value.audioSha256 === 'string' && /^[a-f\d]{64}$/.test(value.audioSha256) && value.confirmed === true
24
+ const sessions = new WeakMap<WorkspaceClient, Map<string, TranscriptionSession>>()
25
+
26
+ /** A task outlives its panel. Only explicit cancellation changes its remote lifetime. */
27
+ export class TranscriptionSession {
28
+ readonly #client: WorkspaceClient
29
+ readonly #directory: string
30
+ readonly #documentId: string
31
+ readonly #key: string
32
+ readonly #listeners = new Set<() => void>()
33
+ #state: TranscriptionSessionState = { sending: false, cancelling: false, checking: false, verified: false, storageFailure: false }
34
+ #intent?: string
35
+ #taskId?: string
36
+ #read?: AbortController
37
+ #timer?: ReturnType<typeof setTimeout>
38
+ #closed = false
39
+
40
+ constructor(client: WorkspaceClient, identity: string, directory: string, documentId: string) {
41
+ this.#client = client; this.#directory = directory; this.#documentId = documentId
42
+ this.#key = `presmium.transcription.v1:${JSON.stringify([identity, directory, documentId])}`
43
+ try {
44
+ const raw = localStorage.getItem(this.#key)
45
+ if (raw) {
46
+ const value: unknown = JSON.parse(raw)
47
+ if (!isRecord(value) || value.documentId !== documentId || value.directory !== directory
48
+ || value.request !== undefined && !recoveredRequest(value.request, documentId)
49
+ || value.task !== undefined && (!isRecord(value.task) || !validIntent(value.task.intent) || typeof value.task.id !== 'string' || !/^[a-f\d]{64}$/.test(value.task.id))) throw new Error('transcription-recovery-invalid')
50
+ this.#state = { ...this.#state, request: value.request as DocumentTranscriptionRequest | undefined, cancelled: value.cancelled === true }
51
+ this.#intent = this.#state.request?.intent ?? (value.task as { intent: string } | undefined)?.intent
52
+ this.#taskId = (value.task as { id: string } | undefined)?.id
53
+ }
54
+ } catch { this.#state = { ...this.#state, storageFailure: true }; try { localStorage.removeItem(this.#key) } catch { /* Storage remains unavailable. */ } }
55
+ }
56
+
57
+ getState = (): TranscriptionSessionState => this.#state
58
+ get intent(): string | undefined { return this.#intent }
59
+ subscribe = (listener: () => void): (() => void) => {
60
+ this.#listeners.add(listener)
61
+ if (this.#listeners.size === 1) void this.refresh()
62
+ return () => { this.#listeners.delete(listener); if (!this.#listeners.size) this.#stopRead() }
63
+ }
64
+ #set(patch: Partial<TranscriptionSessionState>, persist = false): void {
65
+ if (this.#closed) return
66
+ this.#state = { ...this.#state, ...patch }
67
+ if (persist) {
68
+ try {
69
+ localStorage.setItem(this.#key, JSON.stringify({ documentId: this.#documentId, directory: this.#directory, request: this.#state.request, ...this.#taskId && this.#intent ? { task: { id: this.#taskId, intent: this.#intent } } : {}, cancelled: this.#state.cancelled }))
70
+ this.#state.storageFailure = false
71
+ } catch { this.#state.storageFailure = true }
72
+ }
73
+ for (const listener of this.#listeners) listener()
74
+ }
75
+ #stopRead(): void { clearTimeout(this.#timer); this.#timer = undefined; this.#read?.abort(); this.#read = undefined; if (this.#state.checking) this.#set({ checking: false }) }
76
+ #accept(task: TranscriptionTask): void {
77
+ if (task.intent !== this.#intent || task.document !== this.#directory || task.source.documentId !== this.#documentId || this.#state.cancelled && !this.#state.task) return
78
+ const previous = this.#state.task
79
+ if (previous?.id === task.id && task.sequence < previous.sequence) return
80
+ this.#taskId = task.id
81
+ this.#set({ task, request: undefined, sending: false, cancelled: false, verified: true, failure: undefined }, true)
82
+ }
83
+ select(task: TranscriptionTask): boolean {
84
+ if (this.#closed || this.#state.sending || this.#state.cancelling || this.#state.request || active(this.#state.task)
85
+ || task.document !== this.#directory || task.source.documentId !== this.#documentId) return false
86
+ this.#stopRead(); this.#intent = task.intent; this.#taskId = task.id
87
+ this.#set({ request: undefined, task, cancelled: false, verified: true, failure: undefined }, true)
88
+ void this.refresh()
89
+ return true
90
+ }
91
+ async start(request: DocumentTranscriptionRequest, start: () => Promise<TranscriptionTask>): Promise<void> {
92
+ if (this.#closed || this.#state.sending || this.#state.cancelling || this.#state.request || this.#taskId && !this.#state.verified || active(this.#state.task)) throw new Error('transcription-busy')
93
+ const sent = structuredClone(request)
94
+ if (sent.source.documentId !== this.#documentId) throw new Error('document-identity-changed')
95
+ this.#stopRead(); this.#intent = sent.intent; this.#taskId = undefined
96
+ this.#set({ request: sent, task: undefined, cancelled: false, sending: true, verified: false, failure: undefined }, true)
97
+ try { this.#accept(await start()) }
98
+ catch (error) {
99
+ if (this.#intent !== sent.intent || this.#state.cancelled) return
100
+ const known = error instanceof WorkspaceServerError && (error.status < 500 || ['transcription-engine-unavailable', 'transcription-audio-unavailable', 'transcription-services-unavailable'].includes(error.message))
101
+ || error instanceof Error && ['production-source-changed', 'document-identity-changed'].includes(error.message)
102
+ if (known) this.#intent = undefined
103
+ this.#set({ failure: error instanceof Error ? error.message : 'transcription-request-failed', ...known ? { request: undefined, sending: false } : {} }, true)
104
+ } finally {
105
+ if (this.#intent === sent.intent) { this.#set({ sending: false }); void this.refresh() }
106
+ }
107
+ }
108
+ async refresh(): Promise<void> {
109
+ if (this.#closed || !this.#intent || this.#state.cancelled || this.#read) return
110
+ clearTimeout(this.#timer); this.#timer = undefined
111
+ const intent = this.#intent, controller = new AbortController(); this.#read = controller
112
+ this.#set({ checking: true })
113
+ try {
114
+ const task = await this.#client.readTranscription(this.#directory, this.#taskId ? { id: this.#taskId } : { intent }, controller.signal)
115
+ if (!controller.signal.aborted && this.#intent === intent) this.#accept(task)
116
+ } catch (error) {
117
+ if (!controller.signal.aborted && this.#intent === intent) {
118
+ if (error instanceof WorkspaceServerError && error.message === 'transcription-intent-cancelled') this.#set({ request: undefined, cancelled: true, verified: true, failure: undefined }, true)
119
+ else if (!(error instanceof WorkspaceServerError && error.status === 404 && this.#state.request)) {
120
+ if (error instanceof WorkspaceServerError && error.status === 404 && this.#taskId) { this.#taskId = undefined; this.#intent = undefined }
121
+ this.#set({ failure: error instanceof Error ? error.message : 'transcription-request-failed' }, true)
122
+ }
123
+ }
124
+ } finally {
125
+ if (this.#read === controller) {
126
+ this.#read = undefined
127
+ this.#set({ checking: false })
128
+ if (!this.#closed && this.#listeners.size && this.#intent === intent && (this.#state.request || active(this.#state.task))) this.#timer = setTimeout(() => void this.refresh(), 1000)
129
+ }
130
+ }
131
+ }
132
+ async cancel(): Promise<void> {
133
+ if (this.#closed || !this.#intent || this.#state.cancelling || this.#state.cancelled) return
134
+ const intent = this.#intent
135
+ this.#set({ cancelling: true, failure: undefined })
136
+ try {
137
+ const answer = await this.#client.cancelTranscription(this.#directory, this.#taskId ? { id: this.#taskId } : { intent })
138
+ if (this.#intent !== intent || answer.intent !== intent) return
139
+ if (answer.task) this.#accept(answer.task)
140
+ else if (answer.state === 'cancelled') this.#set({ request: undefined, task: undefined, sending: false, cancelled: true, verified: true, failure: undefined }, true)
141
+ } catch (error) {
142
+ if (this.#intent === intent) this.#set({ failure: error instanceof Error ? error.message : 'transcription-cancel-failed' })
143
+ } finally { if (this.#intent === intent) { this.#set({ cancelling: false }); void this.refresh() } }
144
+ }
145
+ dispose(): void { this.#closed = true; this.#stopRead(); this.#listeners.clear() }
146
+ }
147
+
148
+ export function documentTranscriptionSession(client: WorkspaceClient, identity: string, directory: string, documentId: string): TranscriptionSession {
149
+ let known = sessions.get(client)
150
+ if (!known) sessions.set(client, known = new Map())
151
+ const key = JSON.stringify([identity, directory, documentId])
152
+ let session = known.get(key)
153
+ if (!session) { session = new TranscriptionSession(client, identity, directory, documentId); known.set(key, session) }
154
+ return session
155
+ }
156
+ export function disposeTranscriptionSessions(client: WorkspaceClient): void {
157
+ for (const session of sessions.get(client)?.values() ?? []) session.dispose()
158
+ sessions.delete(client)
159
+ }
@@ -0,0 +1,84 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { DocumentRecordingAdoption } from '@lincyaw/kernel'
3
+ import type { DocumentSessionState } from './document-session.ts'
4
+ import { useWorkbench, useWorkbenchState } from './context.tsx'
5
+
6
+ const requestId = () => `recording_${[...crypto.getRandomValues(new Uint8Array(16))].map(value => value.toString(16).padStart(2, '0')).join('')}`
7
+ function applied(source: DocumentSessionState | undefined, request: DocumentRecordingAdoption): boolean {
8
+ const document = source?.content, step = document?.steps[request.stepId]
9
+ const voice = step && (document.production.stepOverrides[step.id]?.voiceSource ?? document.production.sectionOverrides?.[step.sectionId]?.voiceSource ?? document.production.defaults.voiceSource)
10
+ return Boolean(document?.id === request.source.documentId && voice?.type === 'recording' && voice.assetId === request.assetId && document.assets[request.assetId]?.fingerprint === `sha256:${request.sha256}`)
11
+ }
12
+
13
+ /** Request ownership and selection only; canonical changes and recovery belong to DocumentSession. */
14
+ export function useRecordingAdoption({ identity, sectionId, stepId, active, disabled, candidateAssetId, onAdopted }: {
15
+ identity: string, sectionId: string, stepId: string, active: boolean, disabled: boolean, candidateAssetId?: string, onAdopted(): void,
16
+ }) {
17
+ const workbench = useWorkbench(), source = useWorkbenchState(state => state.authoring)
18
+ const [selection, setSelection] = useState<string>(), [busy, setBusy] = useState(false), [failure, setFailure] = useState<string>()
19
+ const owner = useRef({ active: false, revision: 0 }), request = useRef<AbortController>(), retry = useRef<DocumentRecordingAdoption>(), submitted = useRef<DocumentRecordingAdoption>(), reported = useRef<string>()
20
+ const latest = useRef({ sectionId, stepId, active, onAdopted }); latest.current = { sectionId, stepId, active, onAdopted }
21
+ const document = source?.identity === identity ? source.content : undefined
22
+ const effective = document && (document.production.stepOverrides[stepId]?.voiceSource ?? document.production.sectionOverrides?.[sectionId]?.voiceSource ?? document.production.defaults.voiceSource)
23
+ const selected = selection ?? candidateAssetId ?? (effective?.type === 'recording' ? effective.assetId : '')
24
+ const assets = Object.values(document?.assets ?? {}).filter(asset => asset.kind === 'audio')
25
+ const adopted = Boolean(source && !source.loading && !source.dirty && !source.saving && !source.error && effective?.type === 'recording' && effective.assetId === selected && /^sha256:[a-f\d]{64}$/.test(document?.assets[selected]?.fingerprint ?? ''))
26
+ const current = () => owner.current.active && workbench.getState().authoring?.identity === identity
27
+ useEffect(() => { owner.current.active = true; return () => { owner.current.active = false; request.current?.abort() } }, [])
28
+ useEffect(() => {
29
+ owner.current.revision++; request.current?.abort(); request.current = undefined; setBusy(false); setFailure(undefined)
30
+ }, [sectionId, stepId, active])
31
+ useEffect(() => {
32
+ const sent = submitted.current
33
+ if (!sent || source?.identity !== identity) return
34
+ if (applied(source, sent)) {
35
+ if (retry.current?.id === sent.id) retry.current = undefined
36
+ if (!source.loading && !source.dirty && !source.saving && !source.composing && !source.error && reported.current !== sent.id) {
37
+ reported.current = sent.id
38
+ setFailure(undefined); latest.current.onAdopted()
39
+ }
40
+ } else if (source.version !== sent.source.version && request.current) {
41
+ request.current.abort(); request.current = undefined; setBusy(false); setFailure('production-source-changed')
42
+ }
43
+ }, [identity, source?.identity, source?.version, source?.content, source?.loading, source?.dirty, source?.saving, source?.composing, source?.error])
44
+ const choose = (assetId: string) => {
45
+ owner.current.revision++; request.current?.abort(); request.current = undefined; retry.current = undefined
46
+ setSelection(assetId); setBusy(false); setFailure(undefined)
47
+ }
48
+ const imported = (assetId: string, revision: number) => { if (current() && owner.current.revision === revision) choose(assetId) }
49
+ const adopt = async () => {
50
+ if (!selected || busy || disabled || !active || !current() || adopted) return
51
+ const target = { sectionId, stepId, assetId: selected }, revision = owner.current.revision, controller = new AbortController()
52
+ request.current = controller; setBusy(true); setFailure(undefined)
53
+ const owns = () => current() && request.current === controller && !controller.signal.aborted && owner.current.revision === revision
54
+ && latest.current.active && latest.current.sectionId === target.sectionId && latest.current.stepId === target.stepId
55
+ let sent: DocumentRecordingAdoption | undefined
56
+ try {
57
+ await workbench.flushPendingEdits(controller.signal)
58
+ if (!owns()) return
59
+ const source = workbench.getState().authoring
60
+ if (!source?.version || !source.fingerprint || !source.content || source.identity !== identity || source.dirty || source.saving || source.composing || source.restored || source.error) throw new Error('production-source-changed')
61
+ const expected = { identity, version: source.version }, inspection = await workbench.inspectDocumentAudio(target.assetId, expected, controller.signal)
62
+ if (!owns()) return
63
+ const canonical = { documentId: source.content.id, version: source.version, fingerprint: source.fingerprint }, previous = retry.current
64
+ sent = previous && previous.source.documentId === canonical.documentId && previous.source.version === canonical.version && previous.source.fingerprint === canonical.fingerprint && previous.stepId === target.stepId && previous.assetId === target.assetId && previous.sha256 === inspection.inspection.sha256
65
+ ? previous : { id: requestId(), source: canonical, stepId: target.stepId, assetId: target.assetId, sha256: inspection.inspection.sha256 }
66
+ retry.current = sent; submitted.current = sent
67
+ await workbench.adoptDocumentRecording(sent, expected, controller.signal)
68
+ if (!owns()) return
69
+ if (retry.current === sent) retry.current = undefined
70
+ setFailure(undefined)
71
+ if (reported.current !== sent.id) { reported.current = sent.id; latest.current.onAdopted() }
72
+ } catch (error) {
73
+ if (owns()) {
74
+ const confirmed = sent && applied(workbench.getState().authoring, sent)
75
+ if (confirmed && retry.current === sent) retry.current = undefined
76
+ setFailure(confirmed ? undefined : error instanceof Error ? error.message : 'recording-adoption-failed')
77
+ }
78
+ } finally {
79
+ if (request.current === controller) { request.current = undefined; if (current()) setBusy(false) }
80
+ }
81
+ }
82
+ const cancel = () => { owner.current.revision++; request.current?.abort(); request.current = undefined; setBusy(false); setFailure(undefined) }
83
+ return { assets, selected, choose, imported, revision: () => owner.current.revision, adopt, cancel, busy, failure, adopted }
84
+ }
@@ -0,0 +1,92 @@
1
+ import type { VoiceCloneStatus } from '@lincyaw/kernel'
2
+ import type { Locale } from './messages.ts'
3
+ import { WorkspaceServerError } from './workspace-client.ts'
4
+
5
+ // A Latin name or number inside Chinese text takes a space on each side that does not meet full-width punctuation.
6
+ const spaced = (text: string, after = true) => `${/^[A-Za-z0-9]/.test(text) ? ' ' : ''}${text}${after && /[A-Za-z0-9]$/.test(text) ? ' ' : ''}`
7
+ const clock = (ms: number) => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`
8
+
9
+ const zh = {
10
+ open: '复刻音色…', title: '复刻音色', cloned: '复刻音色', close: '关闭', cancel: '取消',
11
+ description: '用一个人的录音训练出一个合成音色。需要本人授权,并亲口读一遍授权声明。',
12
+ voice: '音色名称', voiceHint: (volcengine: boolean): string => (volcengine
13
+ ? '在火山引擎控制台购买的音色槽位(以 S_ 开头),或者给按量付费的音色起一个名字(字母开头,至少 8 个字母或数字)。'
14
+ : '给这个音色起一个名字,只用字母、数字、“.”、“-”和“_”。'),
15
+ voiceInvalid: '名称只能包含字母、数字、“.”、“-”和“_”,并以字母或数字开头。',
16
+ speaker: '说话人', speakerHint: '录音里是谁的声音。', scope: '用途', scopeHint: '这个音色可以用在哪里,会写进授权记录。', defaultScope: '本工作区讲义的配音',
17
+ statement: '授权声明录音', statementHint: '请说话人本人读出上面的授权声明。', statementText: (speaker: string, scope: string) => `我是${spaced(speaker, false)}。我授权用我的声音训练一个合成音色,只用于${spaced(scope, false)}。`,
18
+ someone: '(说话人的名字)', statementLabel: '授权声明',
19
+ passageLabel: '可以朗读的段落', passage: '今天我们从一个简单的问题开始:一张图片是怎样一步一步生成出来的?先看最基本的想法,再看它为什么有效,最后用几个例子把它们连起来。请用平时讲课的语气,自然地读下去。',
20
+ sample: '声音样本', sampleHint: '10 到 30 秒,只有说话人一个人,环境安静。可以读上面这段。',
21
+ sampleLength: (seconds: number) => `样本长 ${seconds.toFixed(0)} 秒。10 到 30 秒的录音效果最好。`,
22
+ record: '录音', stop: '停止录音', choose: '选择音频文件', listen: '试听', stopListening: '停止试听', clear: '清除',
23
+ empty: '尚未录制或选择', preparing: '正在处理录音…', seconds: (seconds: number) => `${seconds.toFixed(1)} 秒`,
24
+ decodeFailed: '无法读取这段音频。请换一个文件,或重新录制。',
25
+ train: '开始训练', use: '使用这个音色',
26
+ confirmTitle: '确认训练这个音色?', confirmHint: '授权记录会先保存在本机的运营者目录中,然后把样本发送给语音服务训练。',
27
+ costs: (provider: string, volcengine: boolean) => (volcengine
28
+ ? `训练和之后的合成都按${spaced(provider)}的价格计费。按量付费的音色在第一次合成(包括试听)时扣费,之后就不能再训练。`
29
+ : `训练和之后的合成按${spaced(provider)}的价格计费。`),
30
+ confirm: '确认训练',
31
+ state: (status: VoiceCloneStatus, waited: number) => status.state === 'ready'
32
+ ? `训练完成${status.trainingsLeft === undefined ? '' : `,这个音色还可以再训练 ${status.trainingsLeft} 次`}。选择“使用这个音色”回到音色设置。`
33
+ : status.state === 'failed' ? '训练失败。请换一段更清楚的样本后重试。'
34
+ : status.state === 'training' ? `正在训练… 已等待 ${clock(waited)}` : '语音服务暂时找不到这次训练,请稍后查看。',
35
+ failures: {
36
+ notAllowed: '运营者尚未允许使用复刻音色,请让运营者在工作区设置中开启。',
37
+ busy: '语音服务正忙,请稍后重试。', tooLarge: '录音太大,请缩短到 30 秒左右。', format: '语音服务不接受这种音频格式,请重新录制。',
38
+ unsupported: '当前语音服务不支持复刻音色。请在“设置 → 语音服务”中选择支持它的服务。',
39
+ failed: '无法开始训练。录音已保留,请稍后重试。',
40
+ },
41
+ }
42
+
43
+ type VoiceCloneMessages = typeof zh
44
+
45
+ const en: VoiceCloneMessages = {
46
+ open: 'Voice Cloning…', title: 'Voice Cloning', cloned: 'cloned voice', close: 'Close', cancel: 'Cancel',
47
+ description: 'Train a synthetic voice from recordings of one person. They must agree, and read the consent statement aloud themselves.',
48
+ voice: 'Voice name', voiceHint: (volcengine: boolean) => (volcengine
49
+ ? 'A voice slot bought in the Volcengine console (it starts with S_), or a name for a pay-as-you-go voice (a letter first, at least 8 letters or digits).'
50
+ : 'A name for the voice: letters, digits, “.”, “-”, and “_” only.'),
51
+ voiceInvalid: 'Use letters, digits, “.”, “-”, and “_”, starting with a letter or digit.',
52
+ speaker: 'Speaker', speakerHint: 'Whose voice this is.', scope: 'Use', scopeHint: 'Where this voice may be used; it goes into the consent record.', defaultScope: 'the voiceover of this workspace’s documents',
53
+ statement: 'Consent statement recording', statementHint: 'The speaker reads the consent statement above aloud.', statementText: (speaker: string, scope: string) => `I am ${speaker}. I agree that my voice is used to train a synthetic voice, only for ${scope}.`,
54
+ someone: '(the speaker’s name)', statementLabel: 'Consent statement',
55
+ passageLabel: 'A passage to read', passage: 'Today we start from a simple question: how is a picture made, one step after another? First the basic idea, then why it works, and finally a few examples that tie it together. Read it the way you would speak in class, at an easy pace.',
56
+ sample: 'Voice sample', sampleHint: '10 to 30 seconds of the speaker alone, somewhere quiet. Reading the passage above works well.',
57
+ sampleLength: (seconds: number) => `The sample is ${seconds.toFixed(0)} seconds long. Recordings of 10 to 30 seconds work best.`,
58
+ record: 'Record', stop: 'Stop Recording', choose: 'Choose Audio File', listen: 'Play Sample', stopListening: 'Stop Sample', clear: 'Clear',
59
+ empty: 'Nothing recorded or chosen yet', preparing: 'Preparing the recording…', seconds: (seconds: number) => `${seconds.toFixed(1)} s`,
60
+ decodeFailed: 'Couldn’t read this audio. Choose another file, or record again.',
61
+ train: 'Start Training', use: 'Use This Voice',
62
+ confirmTitle: 'Train this voice?', confirmHint: 'The consent record is saved first, in the operator directory on this machine; then the sample is sent to the voice service for training.',
63
+ costs: (provider: string, volcengine: boolean) => (volcengine
64
+ ? `Training and every later voiceover are charged at ${provider}’s prices. A pay-as-you-go voice is charged when it first generates voiceover, listening included, and can’t be trained again after that.`
65
+ : `Training and later voiceover are charged at ${provider}’s prices.`),
66
+ confirm: 'Train',
67
+ state: (status: VoiceCloneStatus, waited: number) => status.state === 'ready'
68
+ ? `Trained${status.trainingsLeft === undefined ? '' : `; it can be trained ${status.trainingsLeft} more times`}. Choose Use This Voice to return to the voice settings.`
69
+ : status.state === 'failed' ? 'Training failed. Try again with a clearer sample.'
70
+ : status.state === 'training' ? `Training… ${clock(waited)} so far` : 'The voice service can’t find this training yet. Check again later.',
71
+ failures: {
72
+ notAllowed: 'The operator has not allowed cloned voices yet. Ask them to turn cloned voices on in the workspace settings.',
73
+ busy: 'The voice service is busy. Try again shortly.', tooLarge: 'The recording is too large. Keep it to about 30 seconds.', format: 'The voice service doesn’t accept this audio format. Record again.',
74
+ unsupported: 'The current voice service doesn’t support Voice Cloning. Choose one that does in Settings → Voice Service.',
75
+ failed: 'Couldn’t start training. The recordings are kept; try again shortly.',
76
+ },
77
+ }
78
+
79
+ export const VOICE_CLONE_MESSAGES: Record<Locale, VoiceCloneMessages> = { 'zh-CN': zh, en }
80
+
81
+ export function cloneFailure(error: unknown, locale: Locale): string {
82
+ const t = VOICE_CLONE_MESSAGES[locale].failures
83
+ const status = error instanceof WorkspaceServerError ? error.status : 0
84
+ const message = error instanceof Error ? error.message : String(error)
85
+ if (status === 403 && /cloned voices are off|not allowed/.test(message)) return t.notAllowed
86
+ if (status === 429) return t.busy
87
+ if (status === 413) return t.tooLarge
88
+ if (status === 415) return t.format
89
+ if (status === 400 && /cannot (?:clone|train cloned) voices/.test(message)) return t.unsupported
90
+ console.warn(`presmium: voice cloning failed: ${message}`)
91
+ return t.failed
92
+ }
@@ -0,0 +1,89 @@
1
+ import { isRecord } from '@lincyaw/kernel'
2
+ import type { VoiceSource } from '@lincyaw/kernel'
3
+ import type { DocumentVoiceScope } from './document-voices.ts'
4
+ import type { WorkspaceClient } from './workspace-client.ts'
5
+
6
+ export interface VoiceDraft { version: string, scope: DocumentVoiceScope, source: VoiceSource | null, replaceOverrides: boolean, rateInput?: string }
7
+ export type VoiceDraftStorageStatus = 'available' | 'unavailable' | 'corrupt'
8
+ interface StoredDraft { draft?: VoiceDraft, status: VoiceDraftStorageStatus, unreadable?: string }
9
+ const drafts = new WeakMap<WorkspaceClient, Map<string, StoredDraft>>()
10
+ const keyOf = (identity: string, stepId: string) => JSON.stringify([identity, stepId])
11
+ const storageKey = (key: string) => `presmium.voice-draft.v2:${key}`
12
+
13
+ /** Only safe shape is checked here; current source IDs, assets and supported values are rechecked on apply. */
14
+ function valid(value: unknown): value is VoiceDraft {
15
+ if (!isRecord(value) || typeof value.version !== 'string' || typeof value.replaceOverrides !== 'boolean' || !isRecord(value.scope) || value.rateInput !== undefined && typeof value.rateInput !== 'string') return false
16
+ const scope = value.scope
17
+ if (!(scope.kind === 'document' || scope.kind === 'page' && typeof scope.sectionId === 'string' || scope.kind === 'step' && typeof scope.stepId === 'string' || scope.kind === 'steps' && Array.isArray(scope.stepIds) && scope.stepIds.every(id => typeof id === 'string'))) return false
18
+ const source = value.source
19
+ if (source === null) return true
20
+ if (!isRecord(source)) return false
21
+ if (source.type === 'none') return true
22
+ if (source.type === 'recording') return typeof source.assetId === 'string'
23
+ if (source.type !== 'tts' || !isRecord(source.voice)) return false
24
+ const voice = source.voice
25
+ return typeof voice.voice === 'string' && (voice.engine === undefined || typeof voice.engine === 'string') && (voice.rate === undefined || typeof voice.rate === 'number' && Number.isFinite(voice.rate)) && (voice.style === undefined || typeof voice.style === 'string')
26
+ }
27
+ function store(client: WorkspaceClient): Map<string, StoredDraft> {
28
+ let known = drafts.get(client)
29
+ if (!known) drafts.set(client, known = new Map())
30
+ return known
31
+ }
32
+ function load(client: WorkspaceClient, key: string): StoredDraft {
33
+ const known = store(client), previous = known.get(key)
34
+ if (previous) return previous
35
+ const entry: StoredDraft = { status: 'available' }
36
+ known.set(key, entry)
37
+ try {
38
+ const raw = sessionStorage.getItem(storageKey(key))
39
+ if (raw !== null) {
40
+ entry.unreadable = raw
41
+ const value: unknown = JSON.parse(raw)
42
+ if (!valid(value)) { entry.status = 'corrupt'; return entry }
43
+ entry.draft = value
44
+ delete entry.unreadable
45
+ }
46
+ } catch { entry.status = entry.unreadable === undefined ? 'unavailable' : 'corrupt' }
47
+ return entry
48
+ }
49
+
50
+ export function readVoiceDraft(client: WorkspaceClient, identity: string, stepId: string): VoiceDraft | undefined {
51
+ const value = load(client, keyOf(identity, stepId)).draft
52
+ return value && structuredClone(value)
53
+ }
54
+
55
+ /** Corrupt storage is left untouched on read; a later write preserves its raw text in one recovery slot. */
56
+ export function voiceDraftStorageStatus(client: WorkspaceClient, identity: string, stepId: string): VoiceDraftStorageStatus {
57
+ return load(client, keyOf(identity, stepId)).status
58
+ }
59
+
60
+ /** Returns whether the browser also kept the draft for a reload. */
61
+ export function keepVoiceDraft(client: WorkspaceClient, identity: string, stepId: string, value: VoiceDraft): boolean {
62
+ if (!valid(value)) throw new Error('voice-draft-invalid')
63
+ const key = keyOf(identity, stepId), entry = load(client, key)
64
+ entry.draft = structuredClone(value)
65
+ try {
66
+ if (entry.unreadable !== undefined) sessionStorage.setItem(`${storageKey(key)}:unreadable`, entry.unreadable)
67
+ sessionStorage.setItem(storageKey(key), JSON.stringify(entry.draft))
68
+ entry.status = 'available'; delete entry.unreadable
69
+ return true
70
+ } catch { entry.status = 'unavailable'; return false }
71
+ }
72
+
73
+ /** A late acknowledgement cannot clear newer memory or persisted input. Failure keeps memory for retry. */
74
+ export function clearVoiceDraft(client: WorkspaceClient, identity: string, stepId: string, expected?: VoiceDraft): boolean {
75
+ const key = keyOf(identity, stepId), entry = load(client, key)
76
+ if (expected && JSON.stringify(entry.draft) !== JSON.stringify(expected)) return false
77
+ try {
78
+ const raw = sessionStorage.getItem(storageKey(key))
79
+ if (expected && raw !== null) {
80
+ let persisted: unknown
81
+ try { persisted = JSON.parse(raw) } catch { entry.status = 'corrupt'; entry.unreadable = raw; return false }
82
+ if (!valid(persisted)) { entry.status = 'corrupt'; entry.unreadable = raw; return false }
83
+ if (JSON.stringify(persisted) !== JSON.stringify(expected)) return false
84
+ }
85
+ sessionStorage.removeItem(storageKey(key))
86
+ delete entry.draft; delete entry.unreadable; entry.status = 'available'
87
+ return true
88
+ } catch { entry.status = 'unavailable'; return false }
89
+ }