Skip to content

Commit

Permalink
support audio spectrogramm (#17)
Browse files Browse the repository at this point in the history
* support audio spectrogramm
  • Loading branch information
lifeart authored Aug 6, 2023
1 parent acae2df commit 03f3c97
Show file tree
Hide file tree
Showing 14 changed files with 309 additions and 24 deletions.
6 changes: 3 additions & 3 deletions demo/index.js

Large diffs are not rendered by default.

13 changes: 5 additions & 8 deletions demo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,13 @@ async function initAnnotator() {
video.pause();
}

const blobs = new Blob([blob], { type: "video/mp4" });

video.src = window.URL.createObjectURL(blobs);

await loadPromise;
const bl = new Blob([blob], { type: "video/mp4" });

const tool = new SmAnnotate(video);

tool.setFrameRate(30);
await tool.setVideoBlob(bl, 30);

await loadPromise;

await tool.addReferenceVideoByURL("./mov_bbb_g.mp4");

Expand Down Expand Up @@ -148,9 +146,8 @@ async function initAnnotator() {
const file = videoInput.files[0];
const blobs = new Blob([file], { type: file.type });

const mediaUrl = window.URL.createObjectURL(blobs);
await tool.setVideoBlob(blobs, parseInt(fps, 10));

await tool.setVideoUrl(mediaUrl, parseInt(fps, 10));
});

refVideoInput.addEventListener("change", async (e) => {
Expand Down
6 changes: 3 additions & 3 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions dist/types/core.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export declare class AnnotationTool extends AnnotationToolBase<IShape> {
}[];
prevFrame(): void;
nextFrame(): void;
removeGlobalShape(shapeType: IShape['type']): void;
addGlobalShape(shape: IShape): void;
get selectedColor(): string;
get selectedStrokeSize(): number;
Expand Down Expand Up @@ -57,6 +58,7 @@ export declare class AnnotationTool extends AnnotationToolBase<IShape> {
set undoStack(shapes: IShape[][]);
get pixelRatio(): number;
constructor(videoElement: HTMLVideoElement | HTMLImageElement);
setVideoBlob(blob: Blob, fps?: number): Promise<void>;
setVideoUrl(url: string, fps?: number): Promise<void>;
enableVideoFrameBuffer(): void;
hide(): void;
Expand Down
45 changes: 45 additions & 0 deletions dist/types/plugins/audio-peaks.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { IShapeBase, BasePlugin, ToolPlugin } from "./base";
import { AnnotationTool } from "../core";
import type { PeakData } from "webaudio-peaks";
export interface IAudioPeaks extends IShapeBase {
x: number;
y: number;
}
export declare class AudioPeaksPlugin extends BasePlugin<IAudioPeaks> implements ToolPlugin<IAudioPeaks> {
name: string;
canvas: HTMLCanvasElement;
drawCtx: CanvasRenderingContext2D;
constructor(annotationTool: AnnotationTool);
onVideoBlobSet(blob: Blob): Promise<void>;
on(event: string, arg: unknown): void;
extractPeaks(decodedData: AudioBuffer): Promise<PeakData>;
setProps(peaks: PeakData): void;
init(blob: ArrayBuffer): Promise<void>;
initCanvas(): void;
move(shape: IAudioPeaks, dx: number, dy: number): IAudioPeaks;
normalize(shape: IAudioPeaks, canvasWidth: number, canvasHeight: number): IAudioPeaks;
onPointerDown(event: PointerEvent): void;
onPointerMove(event: PointerEvent): void;
onPointerUp(event: PointerEvent): void;
props: {
peaks: Int8Array | Int16Array | Int32Array;
theme: {
waveOutlineColor: string;
waveFillColor: string;
waveProgressColor: string;
};
waveHeight: number;
bits: number;
};
reset(): void;
draw(_: IAudioPeaks): void;
get pixelRatio(): number;
get progressBarCoordinates(): {
x: number;
y: number;
width: number;
height: number;
};
clearLocalCanvas(): void;
drawOnCanvas(): void;
}
1 change: 1 addition & 0 deletions dist/types/plugins/base.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export declare class BasePlugin<T extends IShapeBase> {
startY: number;
isDrawing: boolean;
constructor(annotationTool: AnnotationTool);
on(event: string, arg: unknown): void;
get ctx(): CanvasRenderingContext2D;
onDeactivate(): void;
onActivate(): void;
Expand Down
8 changes: 5 additions & 3 deletions dist/types/plugins/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { EraserToolPlugin, IEraser } from "./eraser";
import { IMove, MoveToolPlugin } from "./move";
import { IImage, ImageToolPlugin } from "./image";
import { ICompare, CompareToolPlugin } from "./compare";
export type IShape = IRectangle | ICircle | ILine | IArrow | IText | IEraser | ICurve | IMove | IImage | ICompare;
import { IAudioPeaks, AudioPeaksPlugin } from "./audio-peaks";
export type IShape = IRectangle | ICircle | ILine | IArrow | IText | IEraser | ICurve | IMove | IImage | ICompare | IAudioPeaks;
export type Tool = IShape["type"];
export interface ShapeMap {
rectangle: IRectangle;
Expand All @@ -21,6 +22,7 @@ export interface ShapeMap {
move: IMove;
image: IImage;
compare: ICompare;
"audio-peaks": IAudioPeaks;
}
export type PluginInstances = RectangleToolPlugin | CircleToolPlugin | LineToolPlugin | ArrowToolPlugin | TextToolPlugin | EraserToolPlugin | CurveToolPlugin | MoveToolPlugin | ImageToolPlugin | CompareToolPlugin;
export declare const plugins: (typeof RectangleToolPlugin | typeof CircleToolPlugin | typeof CurveToolPlugin | typeof LineToolPlugin | typeof ArrowToolPlugin | typeof TextToolPlugin | typeof EraserToolPlugin | typeof ImageToolPlugin | typeof MoveToolPlugin | typeof CompareToolPlugin)[];
export type PluginInstances = RectangleToolPlugin | CircleToolPlugin | LineToolPlugin | ArrowToolPlugin | TextToolPlugin | EraserToolPlugin | CurveToolPlugin | MoveToolPlugin | ImageToolPlugin | CompareToolPlugin | AudioPeaksPlugin;
export declare const plugins: (typeof RectangleToolPlugin | typeof CircleToolPlugin | typeof CurveToolPlugin | typeof LineToolPlugin | typeof ArrowToolPlugin | typeof TextToolPlugin | typeof EraserToolPlugin | typeof ImageToolPlugin | typeof MoveToolPlugin | typeof CompareToolPlugin | typeof AudioPeaksPlugin)[];
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"types": "dist/types/index.d.ts",
"devDependencies": {
"esbuild": "^0.17.15",
"typescript": "^5.0.4"
"typescript": "^5.0.4",
"webaudio-peaks": "^1.0.0"
},
"exports": "./dist/index.js",
"scripts": {
Expand Down
15 changes: 13 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export class AnnotationTool extends AnnotationToolBase<IShape> {
this.playbackFrame = newFrame;
}
}

removeGlobalShape(shapeType: IShape['type']) {
this.globalShapes = this.globalShapes.filter((s) => s.type !== shapeType);
}
addGlobalShape(shape: IShape) {
this.globalShapes.push(shape);
}
Expand Down Expand Up @@ -179,10 +181,19 @@ export class AnnotationTool extends AnnotationToolBase<IShape> {
this.init(videoElement);
}


async setVideoBlob(blob: Blob, fps = this.fps) {
const url = URL.createObjectURL(blob);
await this.setVideoUrl(url, fps);
this.plugins.forEach((p) => {
p.on('videoBlobSet', blob);
});
}

async setVideoUrl(url: string, fps = this.fps) {
if (this.videoElement instanceof HTMLImageElement) return;
const video = this.videoElement as HTMLVideoElement;
video.src = url;
video.src = url.toString();
await this.videoElement.load();
this.setFrameRate(fps);
if (this.videoFrameBuffer) {
Expand Down
213 changes: 213 additions & 0 deletions src/plugins/audio-peaks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { IShapeBase, BasePlugin, ToolPlugin } from "./base";
import { AnnotationTool } from "../core";
import type { PeakData } from "webaudio-peaks";

export interface IAudioPeaks extends IShapeBase {
x: number;
y: number;
}

function findMinMaxNumbers(array: Int8Array): [number, number] {
let min = array[0];
let max = array[0];
for (let i = 1; i < array.length; i++) {
if (array[i] < min) min = array[i];
if (array[i] > max) max = array[i];
}
return [min, max];
}

export class AudioPeaksPlugin
extends BasePlugin<IAudioPeaks>
implements ToolPlugin<IAudioPeaks>
{
name = "audio-peaks";
canvas = document.createElement("canvas");
drawCtx!: CanvasRenderingContext2D;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
constructor(annotationTool: AnnotationTool) {
super(annotationTool);
this.drawCtx = this.canvas.getContext("2d")!;
}
async onVideoBlobSet(blob: Blob) {
const buffer = await blob.arrayBuffer();
this.init(buffer);
}
on(event: string, arg: unknown) {
if (event === "videoBlobSet") {
this.onVideoBlobSet(arg as Blob);
}
}
async extractPeaks(decodedData: AudioBuffer) {
const { default: extractPeaks } = await import("webaudio-peaks");
const progressBarWidth = this.progressBarCoordinates.width;
const perPixel = Math.ceil(decodedData.length / progressBarWidth);

//calculate peaks from an AudioBuffer
const peaks = extractPeaks(decodedData, perPixel, true);
return peaks;
}
setProps(peaks: PeakData) {
// normalize peaks to bits
const [min, max] = findMinMaxNumbers(peaks.data[0] as Int8Array);
const maxNumber = Math.pow(2, peaks.bits - 1) - 1;
const minNumber = -Math.pow(2, peaks.bits - 1);
this.props.peaks = peaks.data[0].map((peak) => {
if (peak < 0) {
return Math.round((peak / min) * minNumber);
} else {
return Math.round((peak / max) * maxNumber);
}
});
this.props.bits = peaks.bits;
}
async init(blob: ArrayBuffer) {
try {
const audioContext = new AudioContext();
const decodedData = await audioContext.decodeAudioData(blob);
const peaks = await this.extractPeaks(decodedData);

this.initCanvas();
this.setProps(peaks);

this.annotationTool.removeGlobalShape("audio-peaks");
this.annotationTool.addGlobalShape({
x: 0,
y: 0,
strokeStyle: "red",
fillStyle: "red",
lineWidth: 1,
type: "audio-peaks",
});
this.clearLocalCanvas();
this.drawOnCanvas();
} catch (e) {
this.initCanvas();
this.props.peaks = new Int8Array();
this.annotationTool.removeGlobalShape("audio-peaks");
this.clearLocalCanvas();
console.error(e);
}
}
initCanvas() {
this.canvas.width = this.progressBarCoordinates.width * this.pixelRatio;
this.canvas.height = this.props.waveHeight * this.pixelRatio;
this.drawCtx.scale(this.pixelRatio, this.pixelRatio);
}
move(shape: IAudioPeaks, dx: number, dy: number) {
shape.x += dx;
shape.y += dy;
return shape;
}
normalize(
shape: IAudioPeaks,
canvasWidth: number,
canvasHeight: number
): IAudioPeaks {
return {
...shape,
x: shape.x / canvasWidth,
y: shape.y / canvasHeight,
};
}
onPointerDown(event: PointerEvent) {
return;
}
onPointerMove(event: PointerEvent) {
return;
}
onPointerUp(event: PointerEvent) {
return;
}
props = {
peaks: new Int8Array() as Int8Array | Int16Array | Int32Array,
theme: {
// color of the waveform outline
waveOutlineColor: "rgba(255,192,203,0.7)",
waveFillColor: "grey",
waveProgressColor: "orange",
},
waveHeight: 40,
bits: 16,
};
reset() {
this.clearLocalCanvas();
this.props.peaks = new Int8Array();
this.annotationTool.removeGlobalShape("audio-peaks");
}
draw(_: IAudioPeaks) {
const maybeVideoElement = this.annotationTool
.videoElement as HTMLVideoElement;
if (!maybeVideoElement || maybeVideoElement.tagName !== "VIDEO") {
return;
}

const isMuted = maybeVideoElement.muted;
if (isMuted || maybeVideoElement.volume === 0) {
return;
}

this.ctx.clearRect(
0,
0,
this.annotationTool.canvasWidth,
this.annotationTool.canvasHeight
);

const { waveHeight, theme } = this.props;
const cc = this.ctx;

const h2 = waveHeight / 2;
let y = this.progressBarCoordinates.y - 20;

const { x, width } = this.progressBarCoordinates;
const currentFrame = this.annotationTool.playbackFrame;
const totalFrames = maybeVideoElement.duration * this.annotationTool.fps;

const currentFrameCoordinate =
Math.ceil((currentFrame / totalFrames) * width) + x;

//call its drawImage() function passing it the source canvas directly
this.ctx.drawImage(this.canvas, x, y, width, waveHeight);
cc.fillStyle = theme.waveProgressColor;

cc.fillRect(currentFrameCoordinate, y + 0, 1, h2 * 2);
}
get pixelRatio() {
return this.annotationTool.pixelRatio;
}
get progressBarCoordinates() {
return this.annotationTool.progressBarCoordinates;
}
clearLocalCanvas() {
this.drawCtx.clearRect(0, 0, this.canvas.width, this.canvas.height);
}
drawOnCanvas() {
const { peaks, bits, waveHeight, theme } = this.props;
const cc = this.drawCtx;
let offset = 0;
let shift = 0;

const h2 = waveHeight / 2;
const maxValue = 2 ** (bits - 1);
let y = 0;

const peakSegmentLength = peaks.length;
// console.log('peaks.length', peaks.length, this.progressBarCoordinates.width);

// cc.fillStyle = 'white';
// theme.waveOutlineColor;
cc.fillStyle = theme.waveOutlineColor;

for (let i = 0; i < peakSegmentLength; i += 1) {
// const minPeak = peaks[(i + offset) * 2] / maxValue;
const maxPeak = peaks[(i + offset) * 2 + 1] / maxValue;

// const min = Math.abs(minPeak * h2);
const max = Math.abs(maxPeak * h2);
// cc.fillRect(i + shift, y + 0 + h2 - max, 1, max + min); //

cc.fillRect(i + shift, y + 0 + h2 - max, 1, max ); // + min
}
}
}
3 changes: 3 additions & 0 deletions src/plugins/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export class BasePlugin<T extends IShapeBase> {
constructor(annotationTool: AnnotationTool) {
this.annotationTool = annotationTool;
}
on(event: string, arg: unknown) {
// noop
}
get ctx() {
return this.annotationTool.ctx;
}
Expand Down
Loading

0 comments on commit 03f3c97

Please sign in to comment.