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