@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/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @linto-ai/transcript-ui-plugin-audio
2
+
3
+ Audio playback synced with the transcript: waveform display, active-word/active-turn highlighting as playback progresses, click-to-seek, and per-speaker region coloring on the waveform.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { createAudioPlugin } from "@linto-ai/transcript-ui-plugin-audio"
9
+
10
+ core.use(
11
+ createAudioPlugin({
12
+ // Resolve a Channel/Translation's `audio.src` into a playable URL —
13
+ // add auth, fetch as a blob, whatever your backend requires.
14
+ // (generateUrl/fileId below are just this host's own routing — not
15
+ // part of this library.)
16
+ resolveSrc: async (source) => {
17
+ const response = await fetch(generateUrl(`apps/linto/api/audio/${fileId}`))
18
+ if (!response.ok) throw new Error("Audio unavailable")
19
+ const blob = await response.blob()
20
+ return URL.createObjectURL(blob)
21
+ },
22
+ }),
23
+ )
24
+ ```
25
+
26
+ Any `blob:` URL returned by `resolveSrc` is revoked automatically when the source changes or the plugin is destroyed.
27
+
28
+ `resolveWaveform` is the same idea for precomputed waveform peaks (e.g. fetched from your API) — return `null` to fall back to client-side decoding.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@linto-ai/transcript-ui-plugin-audio",
3
+ "version": "0.9.0",
4
+ "description": "Audio playback plugin for @linto-ai/transcript-ui — waveform, active-word highlighting, seek",
5
+ "keywords": [
6
+ "vue",
7
+ "vue3",
8
+ "transcript",
9
+ "audio",
10
+ "waveform",
11
+ "linto-plugin"
12
+ ],
13
+ "sideEffects": [
14
+ "**/*.vue"
15
+ ],
16
+ "homepage": "https://github.com/linto-ai/linto-studio",
17
+ "bugs": "https://github.com/linto-ai/linto-studio/issues",
18
+ "author": "Tom Darboux <tdarboux@linagora.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/linto-ai/linto-studio.git",
22
+ "directory": "studio-sdk/components/transcript-ui/packages/plugin-audio"
23
+ },
24
+ "type": "module",
25
+ "license": "AGPL-3.0",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "main": "./src/index.ts",
33
+ "types": "./src/index.ts",
34
+ "exports": {
35
+ ".": "./src/index.ts"
36
+ },
37
+ "peerDependencies": {
38
+ "vue": "^3.5.0"
39
+ },
40
+ "dependencies": {
41
+ "@linto-ai/transcript-ui-core": "0.9.0",
42
+ "@linto-ai/transcript-ui-i18n": "0.9.0",
43
+ "@linto-ai/transcript-ui-ui": "0.9.0",
44
+ "wavesurfer.js": "^7.12.1"
45
+ }
46
+ }
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { ref, toRef } from 'vue'
3
+ import AudioPlayerControls from './AudioPlayerControls.vue'
4
+ import { useAudioPlayer } from './useAudioPlayer'
5
+
6
+ const props = defineProps<{
7
+ audioSrc?: string
8
+ }>()
9
+
10
+ const waveformRef = ref<HTMLElement | null>(null)
11
+
12
+ const {
13
+ isPlaying,
14
+ isReady,
15
+ isLoading,
16
+ volume,
17
+ playbackRate,
18
+ isMuted,
19
+ formattedCurrentTime,
20
+ formattedDuration,
21
+ togglePlay,
22
+ seekTo,
23
+ pause,
24
+ skip,
25
+ setVolume,
26
+ cyclePlaybackRate,
27
+ toggleMute,
28
+ } = useAudioPlayer({
29
+ containerRef: waveformRef,
30
+ audioSrc: toRef(() => props.audioSrc),
31
+ })
32
+
33
+ defineExpose({ seekTo, pause })
34
+ </script>
35
+
36
+ <template>
37
+ <footer class="audio-player">
38
+ <div
39
+ ref="waveformRef"
40
+ class="waveform-container"
41
+ :class="{ 'waveform-container--loading': isLoading }" />
42
+ <AudioPlayerControls
43
+ :is-playing="isPlaying"
44
+ :current-time="formattedCurrentTime"
45
+ :duration="formattedDuration"
46
+ :volume="volume"
47
+ :playback-rate="playbackRate"
48
+ :is-muted="isMuted"
49
+ :is-ready="isReady"
50
+ @toggle-play="togglePlay"
51
+ @skip-back="skip(-10)"
52
+ @skip-forward="skip(10)"
53
+ @update:volume="setVolume"
54
+ @toggle-mute="toggleMute"
55
+ @cycle-playback-rate="cyclePlaybackRate" />
56
+ </footer>
57
+ </template>
58
+
59
+ <style scoped>
60
+ .audio-player {
61
+ border-top: 1px solid var(--color-border);
62
+ background-color: var(--color-surface);
63
+ flex-shrink: 0;
64
+ }
65
+
66
+ .waveform-container {
67
+ min-height: 32px;
68
+ }
69
+
70
+ .waveform-container--loading {
71
+ background: linear-gradient(
72
+ 90deg,
73
+ var(--color-border-light, var(--color-border)) 25%,
74
+ var(--color-border) 50%,
75
+ var(--color-border-light, var(--color-border)) 75%
76
+ );
77
+ background-size: 200% 100%;
78
+ animation: shimmer 1.5s ease-in-out infinite;
79
+ border-radius: var(--radius-sm);
80
+ }
81
+
82
+ @keyframes shimmer {
83
+ 0% {
84
+ background-position: 200% 0;
85
+ }
86
+ 100% {
87
+ background-position: -200% 0;
88
+ }
89
+ }
90
+
91
+ @media (prefers-reduced-motion: reduce) {
92
+ .waveform-container--loading {
93
+ animation: none;
94
+ }
95
+ }
96
+ </style>
@@ -0,0 +1,207 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-vue-next'
4
+ import { Button } from '@linto-ai/transcript-ui-ui'
5
+ import { useI18n } from '@linto-ai/transcript-ui-i18n'
6
+
7
+ defineProps<{
8
+ isPlaying: boolean
9
+ currentTime: string
10
+ duration: string
11
+ volume: number
12
+ playbackRate: number
13
+ isMuted: boolean
14
+ isReady: boolean
15
+ }>()
16
+
17
+ const emit = defineEmits<{
18
+ togglePlay: []
19
+ skipBack: []
20
+ skipForward: []
21
+ 'update:volume': [value: number]
22
+ toggleMute: []
23
+ cyclePlaybackRate: []
24
+ }>()
25
+
26
+ const { t } = useI18n()
27
+
28
+ const showVolumeSlider = ref(false)
29
+
30
+ function onVolumeInput(event: Event) {
31
+ const target = event.target as HTMLInputElement
32
+ emit('update:volume', parseFloat(target.value))
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <div class="player-controls">
38
+ <div class="controls-left">
39
+ <Button
40
+ variant="transparent"
41
+ size="md"
42
+ class="skip-button"
43
+ :aria-label="t('player.skipBack')"
44
+ :disabled="!isReady"
45
+ @click="emit('skipBack')"
46
+ >
47
+ <template #icon><SkipBack :size="16" /></template>
48
+ </Button>
49
+
50
+ <Button
51
+ variant="transparent"
52
+ size="md"
53
+ class="play-button"
54
+ :aria-label="isPlaying ? t('player.pause') : t('player.play')"
55
+ :disabled="!isReady"
56
+ @click="emit('togglePlay')"
57
+ >
58
+ <template #icon>
59
+ <Pause v-if="isPlaying" :size="20" />
60
+ <Play v-else :size="20" />
61
+ </template>
62
+ </Button>
63
+
64
+ <Button
65
+ variant="transparent"
66
+ size="md"
67
+ class="skip-button"
68
+ :aria-label="t('player.skipForward')"
69
+ :disabled="!isReady"
70
+ @click="emit('skipForward')"
71
+ >
72
+ <template #icon><SkipForward :size="16" /></template>
73
+ </Button>
74
+ </div>
75
+
76
+ <div class="controls-time">
77
+ <time class="time-display">{{ currentTime }}</time>
78
+ <span class="time-separator">/</span>
79
+ <time class="time-display">{{ duration }}</time>
80
+ </div>
81
+
82
+ <div class="controls-right">
83
+ <div
84
+ class="volume-group"
85
+ @mouseenter="showVolumeSlider = true"
86
+ @mouseleave="showVolumeSlider = false"
87
+ >
88
+ <Button
89
+ variant="transparent"
90
+ size="md"
91
+ :aria-label="isMuted ? t('player.unmute') : t('player.mute')"
92
+ :disabled="!isReady"
93
+ @click="emit('toggleMute')"
94
+ >
95
+ <template #icon>
96
+ <VolumeX v-if="isMuted" :size="16" />
97
+ <Volume2 v-else :size="16" />
98
+ </template>
99
+ </Button>
100
+ <input
101
+ v-show="showVolumeSlider"
102
+ type="range"
103
+ class="volume-slider"
104
+ min="0"
105
+ max="1"
106
+ step="0.05"
107
+ :value="volume"
108
+ :aria-label="t('player.volume')"
109
+ :disabled="!isReady"
110
+ @input="onVolumeInput"
111
+ >
112
+ </div>
113
+
114
+ <Button
115
+ variant="transparent"
116
+ size="md"
117
+ class="speed-button"
118
+ :aria-label="t('player.speed')"
119
+ :disabled="!isReady"
120
+ @click="emit('cyclePlaybackRate')"
121
+ >
122
+ {{ playbackRate }}x
123
+ </Button>
124
+ </div>
125
+ </div>
126
+ </template>
127
+
128
+ <style scoped>
129
+ .player-controls {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: var(--spacing-md);
133
+ padding: var(--spacing-xs) var(--spacing-lg);
134
+ height: 44px;
135
+ }
136
+
137
+ .controls-left {
138
+ display: flex;
139
+ align-items: center;
140
+ gap: var(--spacing-xs);
141
+ }
142
+
143
+ .controls-time {
144
+ display: flex;
145
+ align-items: center;
146
+ gap: var(--spacing-xxs);
147
+ font-family: var(--font-family-mono);
148
+ font-size: var(--font-size-sm);
149
+ color: var(--color-text-muted);
150
+ user-select: none;
151
+ }
152
+
153
+ .time-separator {
154
+ color: var(--color-text-muted);
155
+ opacity: 0.5;
156
+ }
157
+
158
+ .controls-right {
159
+ display: flex;
160
+ align-items: center;
161
+ gap: var(--spacing-xs);
162
+ margin-left: auto;
163
+ }
164
+
165
+ .volume-group {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: var(--spacing-xs);
169
+ }
170
+
171
+ .volume-slider {
172
+ width: 80px;
173
+ height: 4px;
174
+ accent-color: var(--color-primary);
175
+ cursor: pointer;
176
+ }
177
+
178
+ .volume-slider:disabled {
179
+ opacity: 0.5;
180
+ cursor: default;
181
+ }
182
+
183
+ .play-button {
184
+ width: 40px;
185
+ height: 40px;
186
+ }
187
+
188
+ .speed-button {
189
+ font-size: var(--font-size-sm);
190
+ font-family: var(--font-family-mono);
191
+ }
192
+
193
+ @media (max-width: 767px) {
194
+ .skip-button {
195
+ display: none;
196
+ }
197
+
198
+ .volume-slider {
199
+ display: none;
200
+ }
201
+
202
+ .player-controls {
203
+ padding: var(--spacing-xs) var(--spacing-md);
204
+ gap: var(--spacing-sm);
205
+ }
206
+ }
207
+ </style>
package/src/index.ts ADDED
@@ -0,0 +1,196 @@
1
+ import { ref, computed, watch, watchEffect } from "vue"
2
+ import type { Core, CorePlugin, AudioPluginApi, AudioSource } from "@linto-ai/transcript-ui-core"
3
+ import { utils } from "@linto-ai/transcript-ui-core"
4
+ import AudioPlayer from "./AudioPlayer.vue"
5
+
6
+ const { findActiveWord, firstWordStart, lastWordEnd } = utils
7
+
8
+ export type { AudioPluginApi }
9
+ export { AudioPlayer }
10
+
11
+ /**
12
+ * Minimum playback progress (in seconds of media time) between two
13
+ * activeWordId computations. Playback ticks ~60 Hz but the active word only
14
+ * changes at word boundaries (~4 Hz), so recomputing every frame is wasted
15
+ * work. Keep it well under a spoken word's duration so the highlight never
16
+ * lags perceptibly.
17
+ */
18
+ const WORD_TRACK_INTERVAL = 0.05
19
+
20
+ export interface AudioPluginOptions {
21
+ /**
22
+ * Resolves an `AudioSource` into a playable URL. Lets the host add a
23
+ * bearer token, fetch as a blob then `URL.createObjectURL`, etc.
24
+ * When absent, `source.src` is used as is.
25
+ *
26
+ * Any returned `blob:` URL is revoked automatically when the source
27
+ * changes or the plugin is destroyed.
28
+ */
29
+ resolveSrc?: (source: AudioSource) => string | Promise<string>
30
+
31
+ /**
32
+ * Resolves precomputed waveform peaks for an `AudioSource` (e.g. fetched
33
+ * from the API). Raw amplitude values, any scale — the player normalizes
34
+ * them. Return null (or throw) to fall back to client-side decoding.
35
+ */
36
+ resolveWaveform?: (
37
+ source: AudioSource,
38
+ ) => number[] | null | Promise<number[] | null>
39
+ }
40
+
41
+ export function createAudioPlugin(
42
+ options: AudioPluginOptions = {},
43
+ ): CorePlugin {
44
+ return {
45
+ name: "audio",
46
+ components: { player: AudioPlayer },
47
+
48
+ install(core: Core) {
49
+ const currentTime = ref(0)
50
+ const isPlaying = ref(false)
51
+ const activeWordId = ref<string | null>(null)
52
+ const activeTurnId = ref<string | null>(null)
53
+ let seekHandler: ((time: number) => void) | null = null
54
+ let pauseHandler: (() => void) | null = null
55
+
56
+ const rawSource = computed(
57
+ () => core.activeChannel.value?.activeTranslation.value.audio ?? null,
58
+ )
59
+
60
+ const resolvedSrc = ref<string | null>(null)
61
+ const waveform = ref<number[] | null>(null)
62
+ let ownedObjectUrl: string | null = null
63
+
64
+ function revokeOwned() {
65
+ if (ownedObjectUrl) {
66
+ URL.revokeObjectURL(ownedObjectUrl)
67
+ ownedObjectUrl = null
68
+ }
69
+ }
70
+
71
+ const stopSourceWatch = watch(
72
+ rawSource,
73
+ async (source) => {
74
+ revokeOwned()
75
+ resolvedSrc.value = null
76
+ waveform.value = null
77
+ if (!source) return
78
+
79
+ // Resolved alongside the src; a failure here only disables the
80
+ // precomputed waveform, never the audio itself.
81
+ const waveformPromise = options.resolveWaveform
82
+ ? Promise.resolve(options.resolveWaveform(source)).catch((err) => {
83
+ console.warn("[audio] resolveWaveform failed", err)
84
+ return null
85
+ })
86
+ : Promise.resolve(null)
87
+
88
+ try {
89
+ const [url, peaks] = await Promise.all([
90
+ options.resolveSrc
91
+ ? options.resolveSrc(source)
92
+ : Promise.resolve(source.src),
93
+ waveformPromise,
94
+ ])
95
+ // Peaks are set before the src: the player creates WaveSurfer
96
+ // when the src changes and reads the waveform at that point.
97
+ waveform.value = peaks?.length ? peaks : null
98
+ resolvedSrc.value = url
99
+ if (url.startsWith("blob:")) ownedObjectUrl = url
100
+ } catch (err) {
101
+ console.error("[audio] resolveSrc failed", err)
102
+ }
103
+ },
104
+ { immediate: true },
105
+ )
106
+
107
+ const src = computed(() => resolvedSrc.value)
108
+
109
+ // Media time of the last activeWordId computation, used to throttle.
110
+ // -Infinity forces a compute on the first tick after playback starts.
111
+ let lastComputeTime = Number.NEGATIVE_INFINITY
112
+
113
+ // Single source of truth: computes activeTurnId / activeWordId.
114
+ // No reset to null on pause: the last known position is kept.
115
+ const stopTracker = watchEffect(() => {
116
+ // Read BOTH refs unconditionally so the effect tracks currentTime even
117
+ // while paused — otherwise seeking/scrubbing (e.g. clicking a word)
118
+ // while paused would never move the highlight.
119
+ const time = currentTime.value
120
+ const playing = isPlaying.value
121
+
122
+ // Throttle only during continuous playback (the word can't have changed
123
+ // within WORD_TRACK_INTERVAL). A backward jump is negative and falls
124
+ // through; while paused, any seek recomputes.
125
+ if (playing) {
126
+ const elapsed = time - lastComputeTime
127
+ if (elapsed >= 0 && elapsed < WORD_TRACK_INTERVAL) return
128
+ }
129
+ lastComputeTime = time
130
+
131
+ const translation = core.activeChannel.value?.activeTranslation.value
132
+ if (!translation) return
133
+
134
+ for (const turn of translation.turns.value) {
135
+ // Derive the turn's span from its words: after a split, the turn
136
+ // attrs go stale (the first half keeps the whole original span, the
137
+ // second half has none), which would overlap and pick the wrong turn.
138
+ // The words carry the correct per-wid timestamps, so trust them and
139
+ // only fall back to the turn attrs for word-less (live) turns.
140
+ // First/last DEFINED word times: robust to words with no timestamp
141
+ // (freshly typed, or split/merge products) sitting anywhere, and to
142
+ // the stale turn attrs after a split. Fall back to the turn attrs for
143
+ // fully word-less (live text-only) turns.
144
+ const words = turn.words
145
+ const start = firstWordStart(words) ?? turn.startTime
146
+ const end = lastWordEnd(words) ?? turn.endTime
147
+ if (start != null && end != null && time >= start && time <= end) {
148
+ activeTurnId.value = turn.id
149
+ // Returns null when no timestamped word matches (e.g. the playhead
150
+ // sits over an untimed, just-typed word) — no stale highlight.
151
+ activeWordId.value = findActiveWord(words, time)
152
+ return
153
+ }
154
+ }
155
+ })
156
+
157
+ function seekTo(time: number) {
158
+ seekHandler?.(time)
159
+ }
160
+
161
+ function setSeekHandler(fn: ((time: number) => void) | null) {
162
+ seekHandler = fn
163
+ }
164
+
165
+ function pause() {
166
+ pauseHandler?.()
167
+ }
168
+
169
+ function setPauseHandler(fn: (() => void) | null) {
170
+ pauseHandler = fn
171
+ }
172
+
173
+ const api: AudioPluginApi = {
174
+ currentTime,
175
+ isPlaying,
176
+ src,
177
+ waveform,
178
+ activeWordId,
179
+ activeTurnId,
180
+ seekTo,
181
+ setSeekHandler,
182
+ pause,
183
+ setPauseHandler,
184
+ }
185
+
186
+ core.audio = api
187
+
188
+ return () => {
189
+ stopSourceWatch()
190
+ stopTracker()
191
+ revokeOwned()
192
+ core.audio = undefined
193
+ }
194
+ },
195
+ }
196
+ }