Mobile

WaveformView

Draws an audio file's waveform on the phone, with scrubbing by finger, a current-time line, and a reduced-motion form.

#mobile#interactive#audio#waveform#player

Demo

Preview

Voice note from Ana

0:00 / 0:00

Not moved yet.

Peaks the server already knew

0:00 / 0:00

A file that cannot be read

0:00 / 0:00

Get it

It needs theme.qwe, as every component does, and button.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/button.qwe$ curl -O https://quillolang.org/components/ui/waveform_view.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/waveform_view.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/waveform_view.qwe
// 2.5 seconds at 4000 Hz: one of silence, one of a loud tone, half of it at half the height.let waveform_view_demo_wav = "data:audio/wav;base64,UklGRjQnAABXQVZFZm10IBAAAAABAAEAoA8AAKAPAAABAAgAZGF0YRAnAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoC65/352qdsNQ8BDzVsp9r5/ee6gEYZAwcmWZTL8f/xy5RZJgcDGUaAuuf9+dqnbDUPAQ81bKfa+f3nuoBGGQMHJlmUy/H/8cuUWSYHAxlGgLrn/fnap2w1DwEPNWyn2vn957qARhkDByZZlMvx//HLlFkmBwMZRoCds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1jgJ2zvrytk3ZbSEFIW3aTrby+s52AY01CRFNtiqW4v7ilim1TREJNY4Cds768rZN2W0hBSFt2k628vrOdgGNNQkRTbYqluL+4pYptU0RCTWOAnbO+vK2TdltIQUhbdpOtvL6znYBjTUJEU22Kpbi/uKWKbVNEQk1j"component WaveformViewDemoLive() {    state at = -1    <div.demo-stack>        <WaveformView src={waveform_view_demo_wav} bars={50} step={0.5} label="Voice note from Ana" on_seek={(s) => { at = s }}/>        <p.demo-note id="waveform-view-demo-note">{at < 0 ? "Not moved yet." : `on_seek got ${round(at, 1)} s`}</p>    </div>}view WaveformViewDemo() =    <div.demo-stack>        <WaveformViewDemoLive/>        <WaveformView src={waveform_view_demo_wav} peaks={[0, 0.25, 0.5, 1, "bad", 2, -1, 0.75]} label="Peaks the server already knew"/>        <WaveformView src="data:audio/wav;base64,AAAA" label="A file that cannot be read"/>    </div>

Source

ui/waveform_view.qwe
// WaveformView: draws an audio file's waveform, plays it, and lets a finger scrub along// it, with a line at the current time. The file is decoded on the phone; give `peaks` when// the server knows them, and nothing is decoded. A drag up or down is left to the page, so// a list of these still scrolls. Keys work on the waveform as on a slider, and the buttons// beside it do the same without any gesture. With reduced motion the line and the played// part stop gliding and step from bar to bar.////   <WaveformView src="/notes/42.m4a" label="Voice note from Ana"/>//   <WaveformView src={url} peaks={[0.1, 0.8, 0.4, 0.2]} on_seek={(s) => { at = s }}/>//// src: the audio. It is read with fetch, so another site must allow that; if it will not// or the file is too long to decode, a flat line stands in and the sound still plays.// peaks: heights from 0 to 1, one a bar, in place of decoding. bars: how many to draw when// decoding (96; 8 to 512). label: what screen readers call it ("Audio"). step: seconds an// arrow key or a button skips (5). max_decode: the most bytes to decode (40000000), since a// decoded hour fills a phone's memory. on_seek(seconds): inside one of your components,// called when a scrub, a key or a button lands.//// Needs Button: `import ui/button.qwe use Button`.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonlet waveform_view_css = css`.ui-waveform-view { --ui-waveform-view-p: 0; container-type: inline-size; display: grid; gap: 10px; width: 100%; padding: 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-waveform-view-title { margin: 0; font-weight: 600; }.ui-waveform-view-track {  container-type: inline-size; position: relative; height: 88px; overflow: hidden; border-radius: var(--ui-radius-sm); background: var(--ui-subtle);  touch-action: pan-y; cursor: ew-resize; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; outline: none;}.ui-waveform-view-track:focus-visible { box-shadow: var(--ui-focus); }.ui-waveform-view-track svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }.ui-waveform-view-all { fill: var(--ui-muted); opacity: .55; }.ui-waveform-view-played svg { fill: var(--ui-accent); }.ui-waveform-view-played { position: absolute; inset: 0; clip-path: inset(0 calc(100% - var(--ui-waveform-view-p) * 1%) 0 0); pointer-events: none; }.ui-waveform-view-line { position: absolute; left: 0; top: 0; bottom: 0; width: 2px; background: var(--ui-danger); transform: translateX(calc(var(--ui-waveform-view-p) * 1cqw - 1px)); pointer-events: none; }.ui-waveform-view-tip {  position: absolute; left: 50%; top: 6px; padding: 2px 8px; border-radius: 999px; background: rgba(0, 0, 0, .7); color: #fff; font: 600 13px/1.4 var(--ui-font);  font-variant-numeric: tabular-nums; transform: translateX(-50%); opacity: 0; pointer-events: none;}/* An attribute, not a class: the root's class is rewritten when the reduced-motion setting changes, which would drop this in mid-scrub. */.ui-waveform-view[data-scrubbing] .ui-waveform-view-tip { opacity: 1; }.ui-waveform-view-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }.ui-waveform-view-clock { margin-left: auto; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-waveform-view-say { margin: 0; color: var(--ui-muted); font-size: 13px; }/* A tablet, or a wide pane: more height for a finger and for the shape of the sound. */@container (min-width: 400px) { .ui-waveform-view-track { height: 132px; } }`// A value kept between two others.fn waveform_view_clamp(v: num, lo: num, hi: num) -> num = v < lo ? lo : v > hi ? hi : v// Seconds as m:ss, rounded down.fn waveform_view_clock(s: num) -> str {    let t = s < 0 ? 0 : floor(s)    let sec = t % 60    `${floor(t / 60)}:${sec < 10 ? "0" : ""}${sec}`}// Heights given as a prop, kept to numbers from 0 to 1, so a bad one is a flat bar.fn waveform_view_clean(xs) -> list {    if xs == null || type_of(xs) != "list" { return [] }    xs | map((x) => type_of(x) == "int" || type_of(x) == "float" ? waveform_view_clamp(x, 0, 1) : 0)}// The loudest of each of `bars` stretches of `total` samples read from `ch`, which can be// indexed (a Float32Array in the browser, a list in a test). Each stretch is looked at in// 256 places, not all of them: an hour has 158 million samples.fn waveform_view_peaks(ch, total: int, bars: int) -> list {    if total <= 0 || bars <= 0 { return [] }    for i in range(bars) => {        let from = floor(i * total / bars)        let to = floor((i + 1) * total / bars)        let span = to - from        let stride = span > 256 ? span / 256 : 1        let count = span > 256 ? 256 : span        let seen = range(count) | map((k) => abs(ch[from + floor(k * stride)] ?? 0))        count > 0 ? max(seen) : 0    }}// Heights scaled so that the loudest is 1. All silence stays silence.fn waveform_view_scale(xs: list) -> list {    if len(xs) == 0 { return [] }    let top = max(xs)    top <= 0 ? xs | map((_x) => 0) : xs | map((x) => round(x / top, 4))}// One path for all the bars, in a box n wide and 100 high, each centred and 0.8 wide. A// path is one element whatever the count, and stretches with its box, so turning the phone// redraws nothing. The least bar is 2 high, so silence is seen as a line.fn waveform_view_path(xs: list) -> str {    let bars = for [i, x] in enumerate(xs) => {        let h = round(x * 100 > 2 ? x * 100 : 2, 2)        `M${round(i + 0.1, 1)} ${round((100 - h) / 2, 2)}h.8v${h}h-.8z`    }    join(bars, "")}// A place along the waveform, moved back to the start of its bar: the reduced-motion form// goes bar by bar. Percentages in, percentages out.fn waveform_view_snap(p: num, n: int) -> num {    if n <= 0 { return p }    round(floor(p * n / 100) * 100 / n, 3)}// Whether a length from the player can be used: a live recording says Infinity.fn waveform_view_known(d) -> bool = d != null && !is_err(d) && d > 0 && d < 86400// The flat line that stands in while nothing better is known.fn waveform_view_flat(n: int) -> list = range(n) | map((_i) => 0)component WaveformView(src: str, peaks: list? = null, bars: int = 96, label: str = "Audio", step: num = 5, max_decode: int = 40000000,                       on_seek = null, class: str? = null) {    style global waveform_view_css    state heights = waveform_view_clean(peaks)    state status = len(waveform_view_clean(peaks)) > 0 ? "ready" : "loading"   // loading, ready, flat    state root = null    state sound = null    state tip = null    state dur = 0    state time = 0    state playing = false    state still = false    state down = null           // { x, y, left, w, on, pointer }, from a finger down to its lifting    let count = waveform_view_clamp(bars, 8, 512)    let shown = len(heights) > 0 ? heights : waveform_view_flat(count)    let path = waveform_view_path(shown)    let at = dur > 0 ? waveform_view_clamp(time, 0, dur) : 0    let paint = (p: num) => {        if root != null {            root.style.setProperty("--ui-waveform-view-p", str(round(still ? waveform_view_snap(p, len(shown)) : p, 3)))        }    }    let follow = () => {        if sound != null && dur > 0 && (down == null || !down.on) { paint(sound.currentTime * 100 / dur) }    }    if playing && !still { every(50, follow) }    let seek = (t: num) => {        if dur > 0 {            let to = waveform_view_clamp(t, 0, dur)            sound.currentTime = to            time = to            paint(to * 100 / dur)            on_seek && on_seek(to)        }    }    let where = (e) => waveform_view_clamp((e.clientX - down.left) / down.w, 0, 1)    // A decoded file's length is used where the player's is not known, as with a recording.    let decode = () => {        let res = js("fetch", src)        if is_err(res) { return null }        let bytes = js("Promise.resolve", res.arrayBuffer())        if is_err(bytes) || bytes.byteLength == 0 || bytes.byteLength > max_decode { return null }        // In a Promise, as a constructor that throws would stop the handler, not give an error value.        let ctx = js("Promise.resolve", js_new("Promise", (done) => { done(js_new("OfflineAudioContext", 1, 1, 44100)) }))        if is_err(ctx) { return null }        let audio = js("Promise.resolve", ctx.decodeAudioData(bytes))        if is_err(audio) { return null }        { heights: waveform_view_scale(waveform_view_peaks(audio.getChannelData(0), audio.length, count)), seconds: audio.duration }    }    let load = () => {        let got = decode()        if got == null {            status = "flat"        } else {            heights = got.heights            if dur == 0 { dur = got.seconds }            status = "ready"        }    }    let flip = () => {        if sound != null {            if playing { sound.pause() } else { sound.play().catch((_ = null) => { playing = false }) }        }    }    <div class={ui_cls(["ui-waveform-view", still && "ui-waveform-view-still", class])} data-state={status}         @mount={(el) => {             root = el             let wish = js("matchMedia", "(prefers-reduced-motion: reduce)")             still = wish.matches             let told = (_e = null) => {                 still = wish.matches                 follow()             }             wish.addEventListener("change", told)             if status == "loading" { load() }             () => {                 wish.removeEventListener("change", told)                 if sound != null { sound.pause() }             }         }}>        <p.ui-waveform-view-title>{label}</p>        <div.ui-waveform-view-track role="slider" tabindex="0" aria-label={`${label}: position`} aria-valuemin="0" aria-valuemax={str(round(dur))} aria-valuenow={str(round(at))}             aria-valuetext={`${waveform_view_clock(at)} of ${waveform_view_clock(dur)}`}             @contextmenu={(e) => { e.preventDefault() }}             @pointerdown={(e) => {                 if e.button == 0 && dur > 0 {                     let r = e.target.closest("[role=slider]").getBoundingClientRect()                     down = { x: e.clientX, y: e.clientY, left: r.left, w: r.width, on: false, pointer: e.pointerId }                 }             }}             @pointermove={(e) => {                 if down != null {                     let dx = e.clientX - down.x                     let dy = e.clientY - down.y                     if !down.on {                         if abs(dx) >= 6 || abs(dy) >= 6 {                             if abs(dy) > abs(dx) {                                 down = null     // up or down is the page's                             } else {                                 e.target.closest("[role=slider]").setPointerCapture(e.pointerId)                                 down = { ...down, on: true }                                 root.dataset.scrubbing = "1"                             }                         }                     }                     if down != null && down.on {                         let part = where(e)                         paint(part * 100)                         tip.textContent = waveform_view_clock(part * dur)                     }                 }             }}             @pointerup={(e) => {                 if down != null {                     let part = where(e)                     root.removeAttribute("data-scrubbing")                     down = null                     seek(part * dur)                 }             }}             @pointercancel={() => {                 root.removeAttribute("data-scrubbing")                 down = null                 follow()             }}             @keydown={(e) => {                 let to = match e.key {                     "ArrowLeft" => time - step                     "ArrowDown" => time - step                     "ArrowRight" => time + step                     "ArrowUp" => time + step                     "PageDown" => time - step * 6                     "PageUp" => time + step * 6                     "Home" => 0                     "End" => dur                     _ => -1                 }                 if to != -1 && dur > 0 {                     e.preventDefault()                     seek(to)                 }             }}>            <svg.ui-waveform-view-all viewBox={`0 0 ${len(shown)} 100`} preserveAspectRatio="none" aria-hidden="true"><path d={path}/></svg>            <div.ui-waveform-view-played aria-hidden="true">                <svg viewBox={`0 0 ${len(shown)} 100`} preserveAspectRatio="none"><path d={path}/></svg>            </div>            <div.ui-waveform-view-line aria-hidden="true"></div>            <span.ui-waveform-view-tip aria-hidden="true" @mount={(el) => { tip = el }}></span>        </div>        <div.ui-waveform-view-bar>            <Button size="lg" label={playing ? "Pause" : "Play"} on_click={flip}>{playing ? "Pause" : "Play"}</Button>            <Button size="lg" variant="outline" tone="neutral" label={`Back ${step} seconds`} disabled={dur == 0} on_click={() => { seek(time - step) }}>−{step}</Button>            <Button size="lg" variant="outline" tone="neutral" label={`Forward ${step} seconds`} disabled={dur == 0} on_click={() => { seek(time + step) }}>+{step}</Button>            <span.ui-waveform-view-clock role="timer" aria-label="Time">{waveform_view_clock(at)} / {waveform_view_clock(dur)}</span>        </div>        {status == "flat" && <p.ui-waveform-view-say>The waveform could not be drawn from this file. It may still play.</p>}        <audio src={src} preload="metadata" @mount={(el) => { sound = el }}               @loadedmetadata={(e) => { if waveform_view_known(e.target.duration) { dur = e.target.duration } }}               @durationchange={(e) => { if waveform_view_known(e.target.duration) { dur = e.target.duration } }}               @timeupdate={(e) => {                   time = e.target.currentTime                   follow()               }}               @play={() => { playing = true }} @pause={() => { playing = false }} @ended={() => { playing = false }}></audio>    </div>}
Thread
47Useful? Like it, or add to its thread below.

Thread 0 posts

On WaveformView: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.