Skip to content

Commit c051ec3

Browse files
committed
feat(vault): audio previews
1 parent 9c8da2a commit c051ec3

7 files changed

Lines changed: 308 additions & 12 deletions

File tree

backend/cmd/server/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ func main() {
166166
cantemoToken: os.Getenv("CANTEMO_TOKEN"),
167167
})
168168
mux.Handle("/vault/image", newVaultImageHandler(cantemoClient, os.Getenv("CANTEMO_TOKEN")))
169+
mux.Handle("/vault/waveform", newVaultWaveformHandler(cantemoClient, os.Getenv("CANTEMO_TOKEN")))
169170

170171
mux.Handle("/", http.HandlerFunc(serveFiles))
171172

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/binary"
7+
"fmt"
8+
"image"
9+
"image/color"
10+
"image/png"
11+
"io"
12+
"net/http"
13+
"os"
14+
"os/exec"
15+
"strconv"
16+
"strings"
17+
18+
"github.com/bcc-code/bcc-media-flows/services/cantemo"
19+
"github.com/bcc-code/mediabank-bridge/log"
20+
lru "github.com/hashicorp/golang-lru/v2"
21+
)
22+
23+
// vaultWaveformHandler renders a peak-waveform PNG of an item's audio
24+
// preview. Audio is decoded by piping through ffmpeg, which handles every
25+
// format we've seen (WAV, MP3, M4A/AAC, FLAC, OGG, ...). Result is cached.
26+
// GET /vault/waveform?vxid=VX-123[&width=400][&height=80].
27+
type vaultWaveformHandler struct {
28+
cantemo *cantemo.Client
29+
cantemoToken string
30+
cache *lru.Cache[string, []byte]
31+
}
32+
33+
func newVaultWaveformHandler(c *cantemo.Client, token string) *vaultWaveformHandler {
34+
cache, _ := lru.New[string, []byte](2048)
35+
if _, err := exec.LookPath("ffmpeg"); err != nil {
36+
log.L.Warn().Err(err).Msg("vault waveform: ffmpeg not found in PATH; /vault/waveform will return 500")
37+
}
38+
return &vaultWaveformHandler{cantemo: c, cantemoToken: token, cache: cache}
39+
}
40+
41+
func (h *vaultWaveformHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
42+
if !PermissionsForEmail(getEmailFromHttp(r)).CanVault() {
43+
http.Error(w, "forbidden", http.StatusForbidden)
44+
return
45+
}
46+
vxID := r.URL.Query().Get("vxid")
47+
if vxID == "" {
48+
http.Error(w, "missing vxid", http.StatusBadRequest)
49+
return
50+
}
51+
52+
width := clampInt(r.URL.Query().Get("width"), 400, 1, 2000)
53+
height := clampInt(r.URL.Query().Get("height"), 80, 8, 400)
54+
55+
cacheKey := vxID + "|" + strconv.Itoa(width) + "x" + strconv.Itoa(height)
56+
if data, ok := h.cache.Get(cacheKey); ok {
57+
writeWaveform(w, data, "HIT")
58+
return
59+
}
60+
61+
previewURL, err := h.cantemo.GetPreviewUrl(vxID)
62+
if err != nil || previewURL == "" {
63+
log.L.Debug().Err(err).Str("vxid", vxID).Msg("vault waveform: preview not available")
64+
http.Error(w, "no preview", http.StatusNotFound)
65+
return
66+
}
67+
68+
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, previewURL, nil)
69+
if err != nil {
70+
http.Error(w, "bad upstream", http.StatusInternalServerError)
71+
return
72+
}
73+
if h.cantemoToken != "" {
74+
req.Header.Set("Auth-Token", h.cantemoToken)
75+
}
76+
resp, err := http.DefaultClient.Do(req)
77+
if err != nil {
78+
log.L.Debug().Err(err).Str("vxid", vxID).Msg("vault waveform: upstream failed")
79+
http.Error(w, "upstream failed", http.StatusBadGateway)
80+
return
81+
}
82+
defer resp.Body.Close()
83+
if resp.StatusCode != http.StatusOK {
84+
http.Error(w, "upstream not OK", resp.StatusCode)
85+
return
86+
}
87+
log.L.Debug().
88+
Str("vxid", vxID).
89+
Str("upstream_content_type", resp.Header.Get("Content-Type")).
90+
Str("upstream_content_length", resp.Header.Get("Content-Length")).
91+
Msg("vault waveform: upstream fetched")
92+
93+
peaks, err := decodePeaks(r.Context(), resp.Body, width, vxID)
94+
if err != nil {
95+
log.L.Debug().Err(err).Str("vxid", vxID).Msg("vault waveform: decode failed")
96+
http.Error(w, "decode failed", http.StatusUnsupportedMediaType)
97+
return
98+
}
99+
100+
img := renderWaveform(peaks, width, height)
101+
var buf bytes.Buffer
102+
if err := png.Encode(&buf, img); err != nil {
103+
http.Error(w, "encode failed", http.StatusInternalServerError)
104+
return
105+
}
106+
107+
out := buf.Bytes()
108+
h.cache.Add(cacheKey, out)
109+
writeWaveform(w, out, "MISS")
110+
}
111+
112+
func writeWaveform(w http.ResponseWriter, data []byte, cacheStatus string) {
113+
w.Header().Set("Content-Type", "image/png")
114+
w.Header().Set("Cache-Control", "private, max-age=86400")
115+
w.Header().Set("X-Cache", cacheStatus)
116+
_, _ = w.Write(data)
117+
}
118+
119+
func clampInt(s string, def, min, max int) int {
120+
if s == "" {
121+
return def
122+
}
123+
n, err := strconv.Atoi(s)
124+
if err != nil {
125+
return def
126+
}
127+
if n < min {
128+
return min
129+
}
130+
if n > max {
131+
return max
132+
}
133+
return n
134+
}
135+
136+
// decodePeaks buffers the upstream audio to a temp file (some containers —
137+
// notably MP4/M4A with moov at end-of-file — require seekable input that
138+
// stdin pipes don't provide), then runs ffmpeg → mono 16-bit LE PCM at 8 kHz.
139+
// 8 kHz is plenty for visual peaks and keeps the buffer small.
140+
func decodePeaks(ctx context.Context, r io.Reader, width int, vxID string) ([]float64, error) {
141+
tmp, err := os.CreateTemp("", "vault-wave-*")
142+
if err != nil {
143+
return nil, fmt.Errorf("create temp: %w", err)
144+
}
145+
tmpPath := tmp.Name()
146+
defer os.Remove(tmpPath)
147+
148+
// Cap the buffer at 500 MB so a runaway upstream can't fill the disk.
149+
const maxBuffered = 500 << 20
150+
written, copyErr := io.Copy(tmp, io.LimitReader(r, maxBuffered))
151+
if cerr := tmp.Close(); cerr != nil && copyErr == nil {
152+
copyErr = cerr
153+
}
154+
if copyErr != nil {
155+
return nil, fmt.Errorf("buffer upstream: %w", copyErr)
156+
}
157+
if written == 0 {
158+
return nil, fmt.Errorf("upstream returned 0 bytes")
159+
}
160+
161+
cmd := exec.CommandContext(ctx, "ffmpeg",
162+
"-hide_banner",
163+
"-nostdin",
164+
"-loglevel", "warning",
165+
"-fflags", "+discardcorrupt",
166+
"-i", tmpPath,
167+
"-ac", "1",
168+
"-ar", "8000",
169+
"-f", "s16le",
170+
"pipe:1",
171+
)
172+
var stderr bytes.Buffer
173+
cmd.Stderr = &stderr
174+
pcm, err := cmd.Output()
175+
stderrStr := strings.TrimSpace(stderr.String())
176+
if err != nil {
177+
return nil, fmt.Errorf("ffmpeg: %w (stderr: %s)", err, stderrStr)
178+
}
179+
if len(pcm) == 0 {
180+
return nil, fmt.Errorf("ffmpeg produced no PCM output (stderr: %s)", stderrStr)
181+
}
182+
log.L.Debug().
183+
Str("vxid", vxID).
184+
Int64("upstream_bytes", written).
185+
Int("pcm_bytes", len(pcm)).
186+
Str("ffmpeg_stderr", stderrStr).
187+
Msg("vault waveform: ffmpeg decoded")
188+
return pcmPeaks(pcm, 1, width), nil
189+
}
190+
191+
// pcmPeaks buckets interleaved 16-bit LE PCM samples into `width` columns
192+
// and returns the max-abs peak per column, normalized to [0, 1] against the
193+
// loudest column.
194+
func pcmPeaks(data []byte, channels, width int) []float64 {
195+
if channels < 1 {
196+
channels = 1
197+
}
198+
bytesPerFrame := 2 * channels
199+
frameCount := len(data) / bytesPerFrame
200+
if frameCount == 0 {
201+
return make([]float64, width)
202+
}
203+
204+
peaks := make([]float64, width)
205+
maxPeak := 0.0
206+
for col := 0; col < width; col++ {
207+
start := col * frameCount / width
208+
end := (col + 1) * frameCount / width
209+
if end <= start {
210+
end = start + 1
211+
}
212+
if end > frameCount {
213+
end = frameCount
214+
}
215+
var peak int32
216+
for i := start; i < end; i++ {
217+
base := i * bytesPerFrame
218+
for c := 0; c < channels; c++ {
219+
s := int32(int16(binary.LittleEndian.Uint16(data[base+c*2:])))
220+
if s < 0 {
221+
s = -s
222+
}
223+
if s > peak {
224+
peak = s
225+
}
226+
}
227+
}
228+
v := float64(peak) / 32768.0
229+
peaks[col] = v
230+
if v > maxPeak {
231+
maxPeak = v
232+
}
233+
}
234+
if maxPeak > 0 {
235+
scale := 1.0 / maxPeak
236+
for i := range peaks {
237+
peaks[i] *= scale
238+
}
239+
}
240+
return peaks
241+
}
242+
243+
func renderWaveform(peaks []float64, width, height int) image.Image {
244+
img := image.NewRGBA(image.Rect(0, 0, width, height))
245+
col := color.RGBA{R: 0x88, G: 0x88, B: 0x88, A: 0xff}
246+
mid := height / 2
247+
for x, p := range peaks {
248+
h := int(p*float64(height)/2 + 0.5)
249+
if h < 1 {
250+
h = 1
251+
}
252+
top := mid - h
253+
bot := mid + h
254+
if top < 0 {
255+
top = 0
256+
}
257+
if bot > height {
258+
bot = height
259+
}
260+
for y := top; y < bot; y++ {
261+
img.SetRGBA(x, y, col)
262+
}
263+
}
264+
return img
265+
}

backend/go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ require (
99
github.com/bcc-code/mediabank-bridge v1.1.1
1010
github.com/go-resty/resty/v2 v2.16.5
1111
github.com/google/uuid v1.6.0
12+
github.com/hashicorp/golang-lru/v2 v2.0.7
1213
github.com/joho/godotenv v1.5.1
1314
github.com/rs/cors v1.11.1
1415
github.com/rs/zerolog v1.34.0
1516
github.com/samber/lo v1.51.0
1617
github.com/stretchr/testify v1.11.1
1718
go.temporal.io/sdk v1.41.1
19+
golang.org/x/image v0.42.0
1820
golang.org/x/net v0.48.0
1921
golang.org/x/sync v0.21.0
2022
google.golang.org/protobuf v1.36.10
@@ -96,7 +98,6 @@ require (
9698
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
9799
github.com/hashicorp/errwrap v1.1.0 // indirect
98100
github.com/hashicorp/go-multierror v1.1.1 // indirect
99-
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
100101
github.com/jlaffaye/ftp v0.2.0 // indirect
101102
github.com/json-iterator/go v1.1.12 // indirect
102103
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
@@ -149,7 +150,6 @@ require (
149150
golang.org/x/arch v0.8.0 // indirect
150151
golang.org/x/crypto v0.46.0 // indirect
151152
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
152-
golang.org/x/image v0.42.0 // indirect
153153
golang.org/x/oauth2 v0.34.0 // indirect
154154
golang.org/x/sys v0.40.0 // indirect
155155
golang.org/x/text v0.38.0 // indirect

backend/go.sum

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -854,8 +854,6 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
854854
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
855855
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
856856
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
857-
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
858-
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
859857
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
860858
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
861859
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -953,8 +951,6 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
953951
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
954952
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
955953
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
956-
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
957-
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
958954
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
959955
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
960956
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=

frontend/app/components/vault/VaultCard.vue

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,20 @@ const previewSrc = computed(
3333
`${props.base}/vault/image?vxid=${encodeURIComponent(props.item.VXID)}&width=400`,
3434
);
3535
36-
const imgSrc = computed(() =>
37-
imgStage.value === "thumbnail" ? thumbSrc.value : previewSrc.value,
36+
// /vault/waveform renders a PNG peak-waveform of the audio preview, used
37+
// directly in place of a thumbnail for audio items (Cantemo doesn't
38+
// auto-generate /thumbnailresource for audio either).
39+
const waveformSrc = computed(
40+
() =>
41+
`${props.base}/vault/waveform?vxid=${encodeURIComponent(props.item.VXID)}&width=400&height=120`,
3842
);
3943
44+
const imgSrc = computed(() => {
45+
if (imgStage.value === "failed") return undefined;
46+
if (props.item.mediaType === "audio") return waveformSrc.value;
47+
return imgStage.value === "thumbnail" ? thumbSrc.value : previewSrc.value;
48+
});
49+
4050
function onImgError() {
4151
if (imgStage.value === "thumbnail" && props.item.mediaType === "image") {
4252
imgStage.value = "preview";

frontend/app/pages/vault/[id].vue

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ const previewSrc = computed(
3434
const thumbSrc = computed(
3535
() => `${base}/vault/thumbnail?vxid=${encodeURIComponent(vxId.value)}`,
3636
);
37+
const waveformSrc = computed(
38+
() =>
39+
`${base}/vault/waveform?vxid=${encodeURIComponent(vxId.value)}&width=1200&height=200`,
40+
);
3741
3842
const isVideo = computed(() => item.value?.mediaType === "video");
3943
const isAudio = computed(() => item.value?.mediaType === "audio");
@@ -46,6 +50,9 @@ const imageSrc = computed(() =>
4650
imageFailed.value ? thumbSrc.value : previewSrc.value,
4751
);
4852
53+
// Waveform may fail to render (e.g. non-MP3 audio); fall back to the icon.
54+
const waveformFailed = ref(false);
55+
4956
const bigIcon = computed(() => {
5057
switch (item.value?.mediaType) {
5158
case "video":
@@ -72,7 +79,7 @@ const lengthLabel = computed(() => {
7279

7380
<template>
7481
<div class="px-6 pt-8 pb-16">
75-
<div class="mx-auto max-w-230">
82+
<div class="mx-auto max-w-7xl">
7683
<!-- header -->
7784
<div class="mb-6 flex items-center gap-4">
7885
<UButton
@@ -105,7 +112,18 @@ const lengthLabel = computed(() => {
105112
v-else-if="isAudio"
106113
class="bg-muted text-muted flex aspect-video w-full flex-col items-center justify-center gap-6 p-6"
107114
>
108-
<UIcon :name="bigIcon" class="size-14 opacity-40" />
115+
<img
116+
v-if="!waveformFailed"
117+
:src="waveformSrc"
118+
alt=""
119+
class="w-full max-w-2xl"
120+
@error="waveformFailed = true"
121+
/>
122+
<UIcon
123+
v-else
124+
:name="bigIcon"
125+
class="size-14 opacity-40"
126+
/>
109127
<audio
110128
:src="previewSrc"
111129
controls

0 commit comments

Comments
 (0)