Skip to content

Commit 9c337ef

Browse files
authored
Merge pull request #1 from majames/feat/audio-bars-mvp
Feat/audio bars mvp
2 parents acff1b6 + 044a46e commit 9c337ef

File tree

14 files changed

+468
-16
lines changed

14 files changed

+468
-16
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
# production
1010
/build
1111

12+
# static assets
13+
/stories/assets
14+
1215
# misc
1316
.DS_Store
1417
.env

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
"private": true,
66
"dependencies": {
77
"@types/jest": "^19.2.3",
8+
"@types/lodash.chunk": "^4.2.2",
9+
"@types/lodash.sum": "^4.0.2",
810
"@types/node": "^7.0.18",
911
"@types/react": "^15.0.24",
1012
"@types/react-dom": "^15.5.0",
13+
"lodash.chunk": "^4.2.0",
14+
"lodash.sum": "^4.0.2",
1115
"react": "^15.5.4",
1216
"react-dom": "^15.5.4"
1317
},
@@ -20,7 +24,7 @@
2024
"scripts": {
2125
"build": "./node_modules/.bin/tsc",
2226
"test": "react-scripts-ts test --env=jsdom",
23-
"storybook": "start-storybook -p 6006",
27+
"storybook": "start-storybook -p 6006 -s ./stories/assets",
2428
"build-storybook": "build-storybook"
2529
}
2630
}

src/analyser/index.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export interface AnalyserSpec {
2+
audioEl: HTMLAudioElement;
3+
audioContext: AudioContext;
4+
}
5+
6+
export class Analyser {
7+
audioEl: HTMLAudioElement;
8+
analyserNode: AnalyserNode;
9+
private source: MediaElementAudioSourceNode;
10+
11+
constructor(spec: AnalyserSpec) {
12+
this.audioEl = spec.audioEl;
13+
this.createAnalyserNode(spec);
14+
}
15+
16+
closeAudioNodes = () => {
17+
const {analyserNode, source} = this;
18+
if (analyserNode) { analyserNode.disconnect(); }
19+
if (source) { source.disconnect(); }
20+
}
21+
22+
private createAnalyserNode = ({audioEl, audioContext}: AnalyserSpec): void => {
23+
this.source = audioContext.createMediaElementSource(audioEl);
24+
25+
this.analyserNode = audioContext.createAnalyser();
26+
const numDataPoints = 512;
27+
this.analyserNode.fftSize = 2 * numDataPoints;
28+
29+
this.source.connect(this.analyserNode);
30+
this.analyserNode.connect(audioContext.destination);
31+
}
32+
}

src/bars/index.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import * as React from 'react';
2-
import * as ReactDOM from 'react-dom';
3-
import {AudioBars} from './';
1+
// import * as React from 'react';
2+
// import * as ReactDOM from 'react-dom';
3+
// import {AudioBars} from './';
44

5-
it('renders without crashing', () => {
6-
const div = document.createElement('div');
7-
ReactDOM.render(<AudioBars />, div);
8-
});
5+
// it('renders without crashing', () => {
6+
// const div = document.createElement('div');
7+
// ReactDOM.render(<AudioBars />, div);
8+
// });

src/bars/index.tsx

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,141 @@
11
import * as React from 'react';
2+
import {Component} from 'react';
3+
import chunk = require('lodash.chunk');
4+
import sum = require('lodash.sum');
5+
6+
import {Analyser} from '../analyser';
7+
import {Dimensions} from '../utils/dimensions';
8+
import {BarsCanvas} from './styled';
9+
10+
export interface AudioBarsProps {
11+
analyser: Analyser;
12+
dimensions?: Dimensions;
13+
}
14+
15+
export class AudioBars extends Component<AudioBarsProps, {}> {
16+
private canvasEl: HTMLCanvasElement;
17+
private canvasContext: CanvasRenderingContext2D;
18+
private animationId: number;
19+
private dataArray: Uint8Array;
20+
21+
componentDidMount() {
22+
const {audioEl} = this.props.analyser;
23+
24+
audioEl.addEventListener('playing', this.onPlaying);
25+
audioEl.addEventListener('pause', this.onPause);
26+
audioEl.addEventListener('ended', this.onEnded);
27+
}
28+
29+
componentWillUnmount() {
30+
this.stopAnimation();
31+
}
232

3-
export class AudioBars extends React.Component<{}, null> {
433
render() {
534
return (
6-
<div>
7-
AudioBars
8-
</div>
35+
<BarsCanvas
36+
width={this.width}
37+
height={this.height}
38+
innerRef={this.onCanvasElMountOrUnmount}
39+
/>
940
);
1041
}
42+
43+
private onCanvasElMountOrUnmount = (ref: HTMLCanvasElement): void => {
44+
if (!ref) {
45+
return;
46+
}
47+
48+
this.canvasEl = ref;
49+
}
50+
51+
private onPlaying = () => {
52+
this.draw();
53+
}
54+
55+
private draw = (): void => {
56+
const context = this.canvasEl.getContext('2d');
57+
58+
if (!context) {
59+
return;
60+
}
61+
62+
this.canvasContext = context;
63+
64+
const {analyserNode} = this.props.analyser;
65+
const bufferLength = analyserNode.frequencyBinCount;
66+
this.dataArray = new Uint8Array(bufferLength);
67+
68+
this.drawBars();
69+
}
70+
71+
private drawBars = (): void => {
72+
const {canvasContext, width: canvasWidth, height: canvasHeight, dataArray} = this;
73+
74+
// clear the canvas
75+
this.canvasContext.clearRect(0, 0, canvasWidth, canvasHeight);
76+
77+
const {analyserNode} = this.props.analyser;
78+
const maxByteValue = 256;
79+
analyserNode.getByteFrequencyData(dataArray);
80+
81+
const numBarsToDraw = Math.min(dataArray.length, 64);
82+
const bufferLength = dataArray.length;
83+
84+
// chunk values if too many to display
85+
const numValuesPerChunk = Math.ceil(bufferLength / numBarsToDraw);
86+
const chunkedData = chunk(dataArray, numValuesPerChunk);
87+
const barValues = chunkedData.map((arr: Array<number>) => sum(arr) / arr.length);
88+
89+
const barWidth = canvasWidth / numBarsToDraw;
90+
91+
// draw the bars
92+
for (let i = 0; i < barValues.length; i++) {
93+
const x = i * barWidth + i;
94+
95+
const percentBarHeight = barValues[i] / maxByteValue;
96+
const barHeight = canvasHeight * percentBarHeight;
97+
98+
const red = Math.round(87 + (169 * percentBarHeight));
99+
canvasContext.fillStyle = `rgba(${red}, 175, 229, 1)`; // TODO: Play with alpha channel based on height?
100+
101+
canvasContext.fillRect(x, canvasHeight - barHeight, barWidth, barHeight);
102+
}
103+
104+
this.animationId = requestAnimationFrame(this.drawBars);
105+
}
106+
107+
private onPause = () => {
108+
this.stopAnimation();
109+
}
110+
111+
private onEnded = () => {
112+
this.stopAnimation();
113+
}
114+
115+
private stopAnimation = () => {
116+
cancelAnimationFrame(this.animationId);
117+
}
118+
119+
private get width(): number {
120+
const defaultWidth = 400;
121+
const {dimensions} = this.props;
122+
123+
if (!dimensions || !dimensions.width) {
124+
return defaultWidth;
125+
}
126+
127+
return dimensions.width;
128+
}
129+
130+
private get height(): number {
131+
const defaultHeight = 400;
132+
const {dimensions} = this.props;
133+
134+
135+
if (!dimensions || !dimensions.height) {
136+
return defaultHeight;
137+
}
138+
139+
return dimensions.height;
140+
}
11141
}

src/bars/styled.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import styled from 'styled-components';
2+
3+
export const BarsCanvas = styled.canvas`
4+
border: 2px solid blue;
5+
border-radius: 3px;
6+
`;

src/circle/index.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import * as React from 'react';
2+
import {Component} from 'react';
3+
import chunk = require('lodash.chunk');
4+
import sum = require('lodash.sum');
5+
6+
import {Analyser} from '../analyser';
7+
import {Dimensions} from '../utils/dimensions';
8+
import {CircleCanvas} from './styled';
9+
10+
export interface AudioCircleProps {
11+
analyser: Analyser;
12+
dimensions?: Dimensions;
13+
}
14+
15+
// TODO implement this using a circle visualisation
16+
export class AudioCircle extends Component<AudioCircleProps, {}> {
17+
private canvasEl: HTMLCanvasElement;
18+
private canvasContext: CanvasRenderingContext2D;
19+
private animationId: number;
20+
private dataArray: Uint8Array;
21+
22+
componentDidMount() {
23+
const {audioEl} = this.props.analyser;
24+
25+
audioEl.addEventListener('playing', this.onPlaying);
26+
audioEl.addEventListener('pause', this.onPause);
27+
audioEl.addEventListener('ended', this.onEnded);
28+
}
29+
30+
componentWillUnmount() {
31+
this.stopAnimation();
32+
}
33+
34+
render() {
35+
return (
36+
<CircleCanvas
37+
width={this.width}
38+
height={this.height}
39+
innerRef={this.onCanvasElMountOrUnmount}
40+
/>
41+
);
42+
}
43+
44+
private onCanvasElMountOrUnmount = (ref: HTMLCanvasElement): void => {
45+
if (!ref) {
46+
return;
47+
}
48+
49+
this.canvasEl = ref;
50+
}
51+
52+
private onPlaying = () => {
53+
this.draw();
54+
}
55+
56+
private draw = (): void => {
57+
const context = this.canvasEl.getContext('2d');
58+
59+
if (!context) {
60+
return;
61+
}
62+
63+
this.canvasContext = context;
64+
65+
const {analyserNode} = this.props.analyser;
66+
const bufferLength = analyserNode.frequencyBinCount;
67+
this.dataArray = new Uint8Array(bufferLength);
68+
69+
this.drawBars();
70+
}
71+
72+
private drawBars = (): void => {
73+
const {canvasContext, width: canvasWidth, height: canvasHeight, dataArray} = this;
74+
75+
// clear the canvas
76+
this.canvasContext.clearRect(0, 0, canvasWidth, canvasHeight);
77+
78+
const {analyserNode} = this.props.analyser;
79+
const maxByteValue = 256;
80+
analyserNode.getByteFrequencyData(dataArray);
81+
82+
const numBarsToDraw = Math.min(dataArray.length, 64);
83+
const bufferLength = dataArray.length;
84+
85+
// chunk values if too many to display
86+
const numValuesPerChunk = Math.ceil(bufferLength / numBarsToDraw);
87+
const chunkedData = chunk(dataArray, numValuesPerChunk);
88+
const barValues = chunkedData.map((arr: Array<number>) => sum(arr) / arr.length);
89+
90+
const barWidth = canvasWidth / numBarsToDraw;
91+
92+
// draw the bars
93+
for (let i = 0; i < barValues.length; i++) {
94+
const x = i * barWidth + i;
95+
96+
const percentBarHeight = barValues[i] / maxByteValue;
97+
const barHeight = canvasHeight * percentBarHeight;
98+
99+
const red = Math.round(87 + (169 * percentBarHeight));
100+
canvasContext.fillStyle = `rgba(${red}, 175, 229, 1)`; // TODO: Play with alpha channel based on height?
101+
102+
canvasContext.fillRect(x, canvasHeight - barHeight, barWidth, barHeight);
103+
}
104+
105+
this.animationId = requestAnimationFrame(this.drawBars);
106+
}
107+
108+
private onPause = () => {
109+
this.stopAnimation();
110+
}
111+
112+
private onEnded = () => {
113+
this.stopAnimation();
114+
}
115+
116+
private stopAnimation = () => {
117+
cancelAnimationFrame(this.animationId);
118+
}
119+
120+
private get width(): number {
121+
const defaultWidth = 400;
122+
const {dimensions} = this.props;
123+
124+
if (!dimensions || !dimensions.width) {
125+
return defaultWidth;
126+
}
127+
128+
return dimensions.width;
129+
}
130+
131+
private get height(): number {
132+
const defaultHeight = 400;
133+
const {dimensions} = this.props;
134+
135+
136+
if (!dimensions || !dimensions.height) {
137+
return defaultHeight;
138+
}
139+
140+
return dimensions.height;
141+
142+
}
143+
}

src/circle/styled.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import styled from 'styled-components';
2+
3+
export const CircleCanvas = styled.canvas`
4+
border: 2px solid blue;
5+
border-radius: 3px;
6+
`;

src/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
export {Analyser} from './analyser';
12
export {AudioBars} from './bars';
3+
export {AudioCircle} from './circle';

src/utils/dimensions.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface Dimensions {
2+
width: number;
3+
height: number;
4+
}

0 commit comments

Comments
 (0)