@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
22import { useMountEffect } from "../../hooks/useMountEffect" ;
33import { useThumbnailLease } from "../../hooks/useThumbnailLease" ;
44import { createThumbnailKey , type ThumbnailPriority } from "../lib/thumbnailScheduler" ;
5+ import { decimatePeaks , loudnessToOpacity } from "./audioWaveformPeaks" ;
56
67interface AudioWaveformProps {
78 audioUrl : string ;
@@ -13,11 +14,68 @@ interface AudioWaveformProps {
1314 projectId : string ;
1415 sessionEpoch : number ;
1516 priority : ThumbnailPriority ;
17+ /** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
18+ muted ?: boolean ;
19+ /** Same media file as a video clip. Draws the 1px parent tick. */
20+ linked ?: boolean ;
1621}
1722
18- const BAR_WIDTH = 2 ;
1923const BAR_STEP = 3 ;
2024
25+ type BarGeometry = { x : number ; width : number ; height : number } ;
26+
27+ function paintWaveformBars (
28+ context : CanvasRenderingContext2D ,
29+ bars : readonly BarGeometry [ ] ,
30+ height : number ,
31+ waveformBarRgb : string ,
32+ waveformBaselineRgb : string ,
33+ amplitudes : readonly number [ ] ,
34+ ) {
35+ bars . forEach ( ( bar , index ) => {
36+ const amplitude = amplitudes [ index ] ?? 0 ;
37+ context . fillStyle = `rgb(${ waveformBaselineRgb } )` ;
38+ context . fillRect ( bar . x , height - 2 , bar . width , 2 ) ;
39+ context . fillStyle = `rgba(${ waveformBarRgb } ,${ loudnessToOpacity ( amplitude ) . toFixed ( 2 ) } )` ;
40+ context . fillRect ( bar . x , height - bar . height , bar . width , bar . height ) ;
41+ } ) ;
42+ }
43+
44+ export function drawWaveformCanvas (
45+ canvas : HTMLCanvasElement ,
46+ peaks : readonly number [ ] ,
47+ muted : boolean ,
48+ trimStartFraction : number ,
49+ trimEndFraction : number ,
50+ ) {
51+ const width = Math . max ( 1 , canvas . clientWidth ) ;
52+ const height = Math . max ( 1 , canvas . clientHeight ) ;
53+ const scale = window . devicePixelRatio || 1 ;
54+ canvas . width = Math . ceil ( width * scale ) ;
55+ canvas . height = Math . ceil ( height * scale ) ;
56+ const context = canvas . getContext ( "2d" ) ;
57+ if ( ! context ) return ;
58+ context . scale ( scale , scale ) ;
59+ context . clearRect ( 0 , 0 , width , height ) ;
60+ const amplitudes = decimatePeaks (
61+ peaks ,
62+ trimStartFraction ,
63+ trimEndFraction ,
64+ Math . max ( 1 , Math . ceil ( width / BAR_STEP ) ) ,
65+ ) ;
66+ const bars = amplitudes . map ( ( amplitude , index ) => ( {
67+ x : ( index * width ) / amplitudes . length ,
68+ width : Math . max ( 1 , width / amplitudes . length ) ,
69+ height : Math . max ( 3 , amplitude * height ) ,
70+ } ) ) ;
71+ const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb" ;
72+ const waveformBarRgb = getComputedStyle ( canvas ) . getPropertyValue ( channelToken ) ;
73+ const waveformBaselineRgb = getComputedStyle ( canvas ) . getPropertyValue (
74+ "--timeline-waveform-baseline-rgb" ,
75+ ) ;
76+ paintWaveformBars ( context , bars , height , waveformBarRgb , waveformBaselineRgb , amplitudes ) ;
77+ }
78+
2179function extractPeaks ( channelData : Float32Array , barCount : number ) : number [ ] {
2280 const peaks : number [ ] = [ ] ;
2381 const samplesPerBar = Math . floor ( channelData . length / barCount ) ;
@@ -92,7 +150,10 @@ export const AudioWaveform = memo(function AudioWaveform({
92150 projectId,
93151 sessionEpoch,
94152 priority,
153+ muted = false ,
154+ linked = false ,
95155} : AudioWaveformProps ) {
156+ const rootRef = useRef < HTMLDivElement | null > ( null ) ;
96157 const canvasRef = useRef < HTMLCanvasElement | null > ( null ) ;
97158 const observerRef = useRef < ResizeObserver | null > ( null ) ;
98159 const cacheKey = waveformUrl ?? audioUrl ;
@@ -121,30 +182,8 @@ export const AudioWaveform = memo(function AudioWaveform({
121182 const draw = useCallback ( ( ) => {
122183 const canvas = canvasRef . current ;
123184 if ( ! canvas || ! peaks ) return ;
124- const width = Math . max ( 1 , canvas . clientWidth ) ;
125- const height = Math . max ( 1 , canvas . clientHeight ) ;
126- const scale = window . devicePixelRatio || 1 ;
127- canvas . width = Math . ceil ( width * scale ) ;
128- canvas . height = Math . ceil ( height * scale ) ;
129- const context = canvas . getContext ( "2d" ) ;
130- if ( ! context ) return ;
131- context . scale ( scale , scale ) ;
132- context . clearRect ( 0 , 0 , width , height ) ;
133- const startFraction = Math . max ( 0 , Math . min ( 1 , trimStartFraction ?? 0 ) ) ;
134- const endFraction = Math . max ( startFraction , Math . min ( 1 , trimEndFraction ?? 1 ) ) ;
135- const start = Math . floor ( startFraction * peaks . length ) ;
136- const end = Math . max ( start + 1 , Math . ceil ( endFraction * peaks . length ) ) ;
137- const span = end - start ;
138- const barCount = Math . floor ( width / BAR_STEP ) ;
139- const waveformBarRgb = getComputedStyle ( canvas ) . getPropertyValue ( "--timeline-waveform-bar-rgb" ) ;
140- for ( let index = 0 ; index < barCount ; index ++ ) {
141- const peakIndex = start + Math . min ( span - 1 , Math . floor ( ( index / barCount ) * span ) ) ;
142- const amplitude = peaks [ peakIndex ] ?? 0 ;
143- const barHeight = Math . max ( 2 , amplitude * height ) ;
144- context . fillStyle = `rgba(${ waveformBarRgb } ,${ ( 0.45 + amplitude * 0.4 ) . toFixed ( 2 ) } )` ;
145- context . fillRect ( index * BAR_STEP , height - barHeight , BAR_WIDTH , barHeight ) ;
146- }
147- } , [ peaks , trimEndFraction , trimStartFraction ] ) ;
185+ drawWaveformCanvas ( canvas , peaks , muted , trimStartFraction ?? 0 , trimEndFraction ?? 1 ) ;
186+ } , [ muted , peaks , trimEndFraction , trimStartFraction ] ) ;
148187
149188 const setCanvasRef = useCallback (
150189 ( canvas : HTMLCanvasElement | null ) => {
@@ -170,51 +209,62 @@ export const AudioWaveform = memo(function AudioWaveform({
170209
171210 useMountEffect ( ( ) => ( ) => observerRef . current ?. disconnect ( ) ) ;
172211
212+ useEffect ( ( ) => {
213+ const clip = rootRef . current ?. closest ( ".timeline-clip" ) ;
214+ if ( ! ( clip instanceof HTMLElement ) ) return ;
215+ if ( muted ) clip . setAttribute ( "data-audio-muted" , "true" ) ;
216+ else clip . removeAttribute ( "data-audio-muted" ) ;
217+ return ( ) => clip . removeAttribute ( "data-audio-muted" ) ;
218+ } , [ muted ] ) ;
219+
173220 return (
174- < div className = "absolute inset-0 overflow-hidden" >
175- < canvas
176- ref = { setCanvasRef }
177- className = "absolute inset-x-0 bottom-0 w-full"
178- style = { { top : 16 } }
179- />
180- { snapshot . status === "loading" && (
181- < div
182- className = "absolute inset-x-0 bottom-0 top-4 animate-pulse"
183- style = { {
184- background : "var(--timeline-thumbnail-shimmer)" ,
185- } }
221+ < div ref = { rootRef } className = "absolute inset-0" >
222+ { linked ? < span className = "timeline-audio-link" aria-hidden = "true" /> : null }
223+ < div className = "absolute inset-0 overflow-hidden" style = { { zIndex : 10 } } >
224+ < canvas
225+ ref = { setCanvasRef }
226+ className = "absolute inset-x-0 bottom-0 w-full"
227+ style = { { top : 16 , height : "calc(100% - 16px)" } }
186228 />
187- ) }
188- { /* Degraded state — the decode failed; say so rather than paint a
189- waveform the author could edit against. */ }
190- { snapshot . status === "error" && (
191- < div
192- className = "absolute inset-x-0 flex items-center justify-center gap-1.5"
193- style = { { top : 16 , bottom : 0 } }
194- >
229+ { snapshot . status === "loading" && (
195230 < div
196- className = "absolute inset-x-0"
231+ className = "absolute inset-x-0 bottom-0 top-4 animate-pulse "
197232 style = { {
198- bottom : "20%" ,
199- height : 2 ,
200- background : "var(--timeline-waveform-error)" ,
233+ background : "var(--timeline-thumbnail-shimmer)" ,
201234 } }
202235 />
203- < span className = "relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500" >
204- waveform unavailable
205- </ span >
206- </ div >
207- ) }
208- { label && (
209- < div className = "absolute inset-x-0 top-0 z-10 px-1.5 py-0.5" >
210- < span
211- className = "block truncate text-[9px] font-semibold leading-tight"
212- style = { { color : labelColor , textShadow : "var(--timeline-waveform-label-shadow)" } }
236+ ) }
237+ { /* Degraded state — the decode failed; say so rather than paint a
238+ waveform the author could edit against. */ }
239+ { snapshot . status === "error" && (
240+ < div
241+ className = "absolute inset-x-0 flex items-center justify-center gap-1.5"
242+ style = { { top : 16 , bottom : 0 } }
213243 >
214- { label }
215- </ span >
216- </ div >
217- ) }
244+ < div
245+ className = "absolute inset-x-0"
246+ style = { {
247+ bottom : "20%" ,
248+ height : 2 ,
249+ background : "var(--timeline-waveform-error)" ,
250+ } }
251+ />
252+ < span className = "relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500" >
253+ waveform unavailable
254+ </ span >
255+ </ div >
256+ ) }
257+ { label && (
258+ < div className = "absolute inset-x-0 top-0 z-10 px-1.5 py-0.5" >
259+ < span
260+ className = "block truncate text-[9px] font-semibold leading-tight"
261+ style = { { color : labelColor , textShadow : "var(--timeline-waveform-label-shadow)" } }
262+ >
263+ { label }
264+ </ span >
265+ </ div >
266+ ) }
267+ </ div >
218268 </ div >
219269 ) ;
220270} ) ;
0 commit comments