@linto-ai/transcript-ui-plugin-audio 0.9.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.
@@ -0,0 +1,390 @@
1
+ import {
2
+ ref,
3
+ computed,
4
+ watch,
5
+ onBeforeUnmount,
6
+ type Ref,
7
+ shallowRef,
8
+ } from "vue"
9
+ import WaveSurfer from "wavesurfer.js"
10
+ import RegionsPlugin, {
11
+ type Region,
12
+ } from "wavesurfer.js/dist/plugins/regions.esm.js"
13
+ import { utils, useCore } from "@linto-ai/transcript-ui-core"
14
+ import type { CoreEventMap, Turn } from "@linto-ai/transcript-ui-core"
15
+
16
+ export interface UseAudioPlayerOptions {
17
+ containerRef: Ref<HTMLElement | null>
18
+ audioSrc: Ref<string | undefined>
19
+ }
20
+
21
+ interface RegionEntry {
22
+ region: Region
23
+ speakerId: string
24
+ }
25
+
26
+ const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const
27
+
28
+ export function useAudioPlayer(options: UseAudioPlayerOptions) {
29
+ const { containerRef, audioSrc } = options
30
+ const core = useCore()
31
+ if (!core.audio) {
32
+ throw new Error("useAudioPlayer requires the audio plugin (core.audio)")
33
+ }
34
+ const audio = core.audio
35
+
36
+ const wavesurfer = shallowRef<WaveSurfer | null>(null)
37
+ const regions = shallowRef<RegionsPlugin | null>(null)
38
+
39
+ const currentTime = audio.currentTime
40
+ const isPlaying = audio.isPlaying
41
+
42
+ const duration = ref(0)
43
+ const isReady = ref(false)
44
+ const isLoading = ref(false)
45
+ const loadError = ref<string | null>(null)
46
+ const volume = ref(1)
47
+ const playbackRate = ref(1)
48
+ const isMuted = ref(false)
49
+
50
+ const formattedCurrentTime = computed(() =>
51
+ utils.formatTime(currentTime.value),
52
+ )
53
+ const formattedDuration = computed(() => utils.formatTime(duration.value))
54
+
55
+ const regionMap = new Map<string, RegionEntry>()
56
+ const eventUnsubs: Array<() => void> = []
57
+
58
+ // ── Region management ────────────────────────────────────────────────
59
+
60
+ function updateOrCreateRegion(turn: Turn): void {
61
+ const regionsPlugin = regions.value
62
+ if (!regionsPlugin) return
63
+ if (turn.startTime == null || turn.endTime == null) {
64
+ removeRegion(turn.id)
65
+ return
66
+ }
67
+ const speaker = turn.speakerId
68
+ ? core.speakers.all.get(turn.speakerId)
69
+ : undefined
70
+ if (!speaker || !turn.speakerId) {
71
+ removeRegion(turn.id)
72
+ return
73
+ }
74
+
75
+ const color = utils.hexToRgba(speaker.color, 0.25)
76
+ const existing = regionMap.get(turn.id)
77
+
78
+ if (existing) {
79
+ existing.region.setOptions({
80
+ start: turn.startTime,
81
+ end: turn.endTime,
82
+ color,
83
+ })
84
+ existing.region.element?.style.setProperty(
85
+ "--region-color",
86
+ speaker.color,
87
+ )
88
+ existing.speakerId = turn.speakerId
89
+ return
90
+ }
91
+
92
+ const region = regionsPlugin.addRegion({
93
+ start: turn.startTime,
94
+ end: turn.endTime,
95
+ color,
96
+ drag: false,
97
+ resize: false,
98
+ })
99
+ region.element?.style.setProperty("--region-color", speaker.color)
100
+ regionMap.set(turn.id, { region, speakerId: turn.speakerId })
101
+ }
102
+
103
+ function removeRegion(turnId: string): void {
104
+ const entry = regionMap.get(turnId)
105
+ if (!entry) return
106
+ entry.region.remove()
107
+ regionMap.delete(turnId)
108
+ }
109
+
110
+ function clearAllRegions(): void {
111
+ for (const { region } of regionMap.values()) region.remove()
112
+ regionMap.clear()
113
+ }
114
+
115
+ function rebuildAllRegions(): void {
116
+ clearAllRegions()
117
+ const turns =
118
+ core.activeChannel.value?.activeTranslation.value.turns.value ?? []
119
+ for (const turn of turns) updateOrCreateRegion(turn)
120
+ }
121
+
122
+ // ── Core event handlers ──────────────────────────────────────────────
123
+
124
+ function onTurnAdd({ turn }: CoreEventMap["turn:add"]): void {
125
+ updateOrCreateRegion(turn)
126
+ }
127
+
128
+ function onTurnUpdate({ turn }: CoreEventMap["turn:update"]): void {
129
+ const existing = regionMap.get(turn.id)
130
+ if (existing) {
131
+ const sameTimes =
132
+ existing.region.start === turn.startTime &&
133
+ existing.region.end === turn.endTime
134
+ const sameSpeaker = existing.speakerId === turn.speakerId
135
+ if (sameTimes && sameSpeaker) return
136
+ }
137
+ updateOrCreateRegion(turn)
138
+ }
139
+
140
+ function onTurnRemove({ turnId }: CoreEventMap["turn:remove"]): void {
141
+ removeRegion(turnId)
142
+ }
143
+
144
+ function onSpeakerUpdate({ speaker }: CoreEventMap["speaker:update"]): void {
145
+ const color = utils.hexToRgba(speaker.color, 0.25)
146
+ for (const [, entry] of regionMap) {
147
+ if (entry.speakerId !== speaker.id) continue
148
+ entry.region.setOptions({ color })
149
+ entry.region.element?.style.setProperty("--region-color", speaker.color)
150
+ }
151
+ }
152
+
153
+ function onSpeakerRemove({
154
+ speakerId,
155
+ }: CoreEventMap["speaker:remove"]): void {
156
+ for (const [turnId, entry] of [...regionMap]) {
157
+ if (entry.speakerId === speakerId) removeRegion(turnId)
158
+ }
159
+ }
160
+
161
+ function onTranslationSync(): void {
162
+ rebuildAllRegions()
163
+ }
164
+
165
+ function onTranslationChange(): void {
166
+ rebuildAllRegions()
167
+ }
168
+
169
+ function onChannelReset(): void {
170
+ clearAllRegions()
171
+ }
172
+
173
+ function attachEventListeners(): void {
174
+ eventUnsubs.push(core.onActiveTranslation("turn:add", onTurnAdd))
175
+ eventUnsubs.push(core.onActiveTranslation("turn:update", onTurnUpdate))
176
+ eventUnsubs.push(core.onActiveTranslation("turn:remove", onTurnRemove))
177
+ eventUnsubs.push(core.on("speaker:update", onSpeakerUpdate))
178
+ eventUnsubs.push(core.on("speaker:remove", onSpeakerRemove))
179
+ eventUnsubs.push(core.on("translation:sync", onTranslationSync))
180
+ eventUnsubs.push(core.on("translation:change", onTranslationChange))
181
+ eventUnsubs.push(core.on("channel:reset", onChannelReset))
182
+ }
183
+
184
+ function detachEventListeners(): void {
185
+ for (const u of eventUnsubs) u()
186
+ eventUnsubs.length = 0
187
+ }
188
+
189
+ // ── WaveSurfer event handlers ────────────────────────────────────────
190
+
191
+ function onPlayerReady(): void {
192
+ const player = wavesurfer.value
193
+ if (!player) return
194
+ isReady.value = true
195
+ isLoading.value = false
196
+ loadError.value = null
197
+ duration.value = player.getDuration()
198
+ rebuildAllRegions()
199
+ attachEventListeners()
200
+ }
201
+
202
+ function onPlayerTimeUpdate(time: number): void {
203
+ currentTime.value = time
204
+ }
205
+
206
+ function onPlayerPlay(): void {
207
+ isPlaying.value = true
208
+ }
209
+
210
+ function onPlayerPause(): void {
211
+ isPlaying.value = false
212
+ }
213
+
214
+ function onPlayerFinish(): void {
215
+ isPlaying.value = false
216
+ }
217
+
218
+ function onPlayerError(err: Error): void {
219
+ isLoading.value = false
220
+ isReady.value = false
221
+ loadError.value = err?.message ?? "Failed to load audio"
222
+ }
223
+
224
+ // ── Player lifecycle ─────────────────────────────────────────────────
225
+
226
+ function initWaveSurfer(container: HTMLElement, src: string): void {
227
+ destroy()
228
+
229
+ isLoading.value = true
230
+ isReady.value = false
231
+ loadError.value = null
232
+
233
+ const regionsPlugin = RegionsPlugin.create()
234
+ regions.value = regionsPlugin
235
+
236
+ // Precomputed peaks (resolved by the audio plugin) let WaveSurfer draw
237
+ // the waveform without fetching and decoding the whole audio file.
238
+ // The duration comes from the document metadata; without it WaveSurfer
239
+ // waits for the media metadata before the first render.
240
+ const precomputed = audio.waveform.value
241
+ const peaks = precomputed?.length
242
+ ? [utils.normalizePeaks(precomputed)]
243
+ : undefined
244
+ const channelDuration = core.activeChannel.value?.duration
245
+
246
+ const player = WaveSurfer.create({
247
+ peaks,
248
+ duration: peaks && channelDuration ? channelDuration : undefined,
249
+ container,
250
+ height: 32,
251
+ waveColor: "#000000ff",
252
+ progressColor: "#5f5f5fff",
253
+ cursorColor: "red",
254
+ cursorWidth: 2,
255
+ barWidth: 3,
256
+ barGap: 2,
257
+ barRadius: 3,
258
+ barHeight: 0.8,
259
+ normalize: true,
260
+ backend: "MediaElement",
261
+ renderFunction: utils.renderWaveform,
262
+ url: src,
263
+ plugins: [regionsPlugin],
264
+ })
265
+
266
+ player.setVolume(volume.value)
267
+ player.setPlaybackRate(playbackRate.value)
268
+ player.setMuted(isMuted.value)
269
+
270
+ player.on("ready", onPlayerReady)
271
+ player.on("timeupdate", onPlayerTimeUpdate)
272
+ player.on("play", onPlayerPlay)
273
+ player.on("pause", onPlayerPause)
274
+ player.on("finish", onPlayerFinish)
275
+ player.on("error", onPlayerError)
276
+
277
+ wavesurfer.value = player
278
+ }
279
+
280
+ function destroy(): void {
281
+ detachEventListeners()
282
+ clearAllRegions()
283
+ if (wavesurfer.value) {
284
+ wavesurfer.value.destroy()
285
+ wavesurfer.value = null
286
+ regions.value = null
287
+ }
288
+ }
289
+
290
+ // ── Public controls ──────────────────────────────────────────────────
291
+
292
+ function play(): void {
293
+ wavesurfer.value?.play()
294
+ }
295
+
296
+ function pause(): void {
297
+ wavesurfer.value?.pause()
298
+ }
299
+
300
+ function togglePlay(): void {
301
+ wavesurfer.value?.playPause()
302
+ }
303
+
304
+ function seekTo(time: number): void {
305
+ const player = wavesurfer.value
306
+ if (!player || duration.value === 0) return
307
+ player.setTime(Math.max(0, Math.min(time, duration.value)))
308
+ }
309
+
310
+ function skip(seconds: number): void {
311
+ seekTo(currentTime.value + seconds)
312
+ }
313
+
314
+ function setVolume(v: number): void {
315
+ const player = wavesurfer.value
316
+ if (!player) return
317
+ volume.value = v
318
+ player.setVolume(v)
319
+ if (v > 0 && isMuted.value) {
320
+ isMuted.value = false
321
+ player.setMuted(false)
322
+ }
323
+ }
324
+
325
+ function toggleMute(): void {
326
+ const player = wavesurfer.value
327
+ if (!player) return
328
+ isMuted.value = !isMuted.value
329
+ player.setMuted(isMuted.value)
330
+ }
331
+
332
+ function setPlaybackRate(rate: number): void {
333
+ const player = wavesurfer.value
334
+ if (!player) return
335
+ playbackRate.value = rate
336
+ player.setPlaybackRate(rate)
337
+ }
338
+
339
+ function cyclePlaybackRate(): void {
340
+ const currentIndex = PLAYBACK_RATES.indexOf(
341
+ playbackRate.value as (typeof PLAYBACK_RATES)[number],
342
+ )
343
+ const nextIndex = (currentIndex + 1) % PLAYBACK_RATES.length
344
+ setPlaybackRate(PLAYBACK_RATES[nextIndex] ?? 1)
345
+ }
346
+
347
+ // ── Wiring ───────────────────────────────────────────────────────────
348
+
349
+ watch(
350
+ [containerRef, audioSrc],
351
+ ([container, src]) => {
352
+ if (container && src) {
353
+ initWaveSurfer(container, src)
354
+ }
355
+ },
356
+ { immediate: true },
357
+ )
358
+
359
+ audio.setSeekHandler(seekTo)
360
+ audio.setPauseHandler(pause)
361
+
362
+ onBeforeUnmount(() => {
363
+ audio.setSeekHandler(null)
364
+ audio.setPauseHandler(null)
365
+ destroy()
366
+ })
367
+
368
+ return {
369
+ currentTime,
370
+ duration,
371
+ isPlaying,
372
+ isReady,
373
+ isLoading,
374
+ loadError,
375
+ volume,
376
+ playbackRate,
377
+ isMuted,
378
+ formattedCurrentTime,
379
+ formattedDuration,
380
+ play,
381
+ pause,
382
+ togglePlay,
383
+ seekTo,
384
+ skip,
385
+ setVolume,
386
+ setPlaybackRate,
387
+ cyclePlaybackRate,
388
+ toggleMute,
389
+ }
390
+ }