@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.
- package/LICENSE +661 -0
- package/README.md +28 -0
- package/package.json +46 -0
- package/src/AudioPlayer.vue +96 -0
- package/src/AudioPlayerControls.vue +207 -0
- package/src/index.ts +196 -0
- package/src/useAudioPlayer.ts +390 -0
|
@@ -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
|
+
}
|