/**
* An AudioFileAspect folds audio file and visuals classes into a single simplified class.
*/
class AudioFileAspect {
/**
* Construct a new AudioFileAspect.
* @param {object} audioContext - An AudioContext.
* @author Lawrence Fyfe
*/
constructor(audioContext) {
this.audioContext = audioContext;
this.fileArray = [];
this.parentElement;
this.parentWidth;
this.parentHeight;
this.audioEndTime = 0;
this.audioAspectDiv;
this.audioInputDiv;
this.controlDiv;
this.frameDiv;
this.timeframe = new Timeframe({
marginTop: 0,
marginRight: MARGIN,
marginBottom: MARGIN_BOTTOM,
marginLeft: MARGIN,
timeAxisTickCount: 10,
backgroundColor: "#ffffff"
});
this.audioPane = new AudioPane(audioContext);
this.audioPane.onPlay = this.startPlaying.bind(this);
this.audioPane.onStop = this.stopPlaying.bind(this);
this.timeframe.addPane(this.audioPane);
this.visualPane = new VisualPane();
this.timeframe.addPane(this.visualPane);
this.zoomTimeframe = new Timeframe({
marginTop: MARGIN,
marginRight: MARGIN,
marginBottom: 0,
marginLeft: MARGIN,
showTimeAxis: false
});
this.zoomVisualPane = new VisualPane();
this.zoomTimeframe.addPane(this.zoomVisualPane);
this.zoomPane = new ZoomPane();
this.zoomPane.onZoom = this.zoomTarget.bind(this);
this.zoomPane.onClear = this.clearTarget.bind(this);
this.zoomTimeframe.addPane(this.zoomPane);
}
/**
* Load an audio file.
* @param {file} file - a file.
*/
loadFile(file) {
this.audioPane.setPlayer(new AudioFilePlayer(this.audioContext, file));
this.audioPane.isReady().then(() => {
this.audioEndTime = this.audioPane.getDuration();
this.visualPane.removeVisualType("waveform");
this.visualPane.addVisual(
new Waveform(this.audioPane.player.bufferPlayer, this.audioEndTime, {color: "hsl(240, 30%, 25%)", opacity: "0.75"})
);
this.zoomVisualPane.removeVisualType("waveform");
this.zoomVisualPane.addVisual(
new Waveform(this.audioPane.player.bufferPlayer, this.audioEndTime, {color: "hsl(240, 30%, 25%)", opacity: "0.5"})
);
});
}
/**
* Initialize this aspect.
* @param {element} parentElement - The parent element.
* @param {number} parentWidth - The parent width.
* @param {number} parentHeight - The parent height.
*/
initialize(parentElement, parentWidth, parentHeight) {
this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
this.parentWidth = parentWidth;
this.parentHeight = parentHeight;
this.zoomHeight = 0.3*parentHeight;
this.audioPane.isReady().then(() => {
this.audioAspectDiv = this.parentElement.append("div")
.attr("class", "audioAspectDiv");
this.audioInputDiv = this.audioAspectDiv.append("div")
.attr("id", "audioInputDiv")
.style("margin-left", "2ch");
var audioFileInput = this.audioInputDiv.append("input")
.attr("type", "file")
.attr("accept", "audio/*")
.style("display", "none")
.on("change", (event) => {
let file = event.target.files[0];
if (file.type.startsWith("audio")) {
this.loadFile(file);
this.erase();
this.initialize(this.frameDiv, this.parentWidth, this.parentHeight, this.audioEndTime);
this.draw();
}
});
this.audioInputDiv.append("button")
.style("font-size", "1rem")
.style("margin-right", "2ch")
.text("Save visuals to SVG")
.on("click", () => {
this.saveToSVG();
});
this.audioInputDiv.append("button")
.style("font-size", "1rem")
.style("margin-right", "2ch")
.text("Select a local audio file")
.on("click", () => {
audioFileInput.node().click();
});
this.controlDiv = this.audioAspectDiv.append("div")
.attr("class", "controlDiv")
.style("display", "flex")
.style("flex-direction", "row")
.style("margin-top", "2ex")
.style("margin-left", MARGIN.toString() + "px");
this.frameDiv = this.audioAspectDiv.append("div")
.attr("class", "frameDiv");
this.audioPane.initializePanel(this.controlDiv);
this.zoomPane.initializePanel(this.controlDiv);
this.zoomTimeframe.initialize(this.frameDiv, parentWidth, this.zoomHeight, this.audioEndTime);
this.timeframe.initialize(this.frameDiv, parentWidth, parentHeight, this.audioEndTime);
});
}
/**
* Draw this aspect.
*/
draw() {
this.audioPane.isReady().then(() => {
this.audioPane.drawPanel();
this.zoomPane.drawPanel();
this.zoomTimeframe.draw();
this.timeframe.draw();
});
}
/**
* Erase this aspect.
*/
erase() {
this.audioPane.isReady().then(() => {
this.audioInputDiv.remove();
this.controlDiv.remove();
this.audioPane.erasePanel();
this.zoomPane.erasePanel();
this.timeframe.erase();
this.zoomTimeframe.erase();
});
}
/**
* When audio starts playing, set the AudioPane as the active pane.
*/
startPlaying() {
this.timeframe.setActivePane(this.audioPane);
}
/**
* When audio stops playing, set the VisualPane as the active pane.
*/
stopPlaying() {
this.timeframe.setActivePane(this.visualPane);
}
/**
* Zoom into the specified time range.
*/
zoomTarget(startTime, endTime) {
this.timeframe.zoomTimeRange(startTime, endTime);
this.timeframe.draw();
}
/**
* Clear all zoom-related values and draw the Timeframe.
*/
clearTarget() {
this.audioPane.playLineTime = 0;
this.audioPane.playLineX = 0;
this.timeframe.resetTimeRange();
this.timeframe.draw();
}
/**
* Save the visual in this aspect to an SVG file.
*/
saveToSVG() {
const serializer = new XMLSerializer();
const blob = new Blob([serializer.serializeToString(this.timeframe.timeFrameSVG.node().cloneNode(true))], {type: "image/svg+xml"});
const link = document.createElement("a");
link.download = "traces.svg";
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);
}
}
/**
* A MIDIFileAspect folds MIDI file and visual classes into a single simplified class.
*/
class MIDIFileAspect {
/**
* Construct a new MIDIFileAspect.
* @param {object} audioContext - An AudioContext.
* @author Lawrence Fyfe
*/
constructor(audioContext) {
this.audioContext = audioContext;
this.readyPromise;
this.parentElement;
this.parentWidth;
this.parentHeight;
this.audioEndTime = 0;
this.midiDiv;
this.midiInputDiv;
this.controlDiv;
this.frameDiv;
this.midiFile;
this.synthesizer;
this.timeframe = new Timeframe({
marginTop: 0,
marginRight: MARGIN,
marginBottom: MARGIN_BOTTOM,
marginLeft: MARGIN,
timeAxisTickCount: 10,
backgroundColor: "#ffffff"
});
this.audioPane = new AudioPane(audioContext);
this.audioPane.onPlay = this.startPlaying.bind(this);
this.audioPane.onStop = this.stopPlaying.bind(this);
this.timeframe.addPane(this.audioPane);
this.visualPane = new VisualPane();
this.timeframe.addPane(this.visualPane);
this.zoomTimeframe = new Timeframe({
marginTop: MARGIN,
marginRight: MARGIN,
marginBottom: 0,
marginLeft: MARGIN,
showTimeAxis: false
});
this.zoomVisualPane = new VisualPane();
this.zoomTimeframe.addPane(this.zoomVisualPane);
this.zoomPane = new ZoomPane();
this.zoomPane.onZoom = this.zoomTarget.bind(this);
this.zoomPane.onClear = this.clearTarget.bind(this);
this.zoomTimeframe.addPane(this.zoomPane);
this.synthesizer = new WavetableSynthesizer(this.audioContext, "organ.json");
}
/**
* Load a MIDI file.
* @param {file} file - A file.
*/
loadFile(file) {
this.readyPromise = new Promise((resolve) => {
file.arrayBuffer().then((buffer) => {
this.synthesizer.isReady().then(() => {
this.midiFile = new MIDIFile();
this.midiFile.readFile(file.name, new Uint8Array(buffer));
let trackNumber = 0;
if (this.midiFile.format === 1 && this.midiFile.ntracks === 2) {
trackNumber = 1;
}
this.audioEndTime = this.midiFile.totalTime;
let noteEvents = this.midiFile.getTrackEventType("Note", trackNumber);
let controllerEvents = this.midiFile.getTrackEventType("Controller", trackNumber);
if (controllerEvents.length > 0) {
this.visualPane.removeVisualType("area");
let sustainEvents = controllerEvents.filter((e) => e.controller === 64);
let sostenutoEvents = controllerEvents.filter((e) => e.controller === 66);
let softPedalEvents = controllerEvents.filter((e) => e.controller === 67);
if (sustainEvents.length > 0) {
this.visualPane.createCurve(
sustainEvents,
{
curveType: Curve.AREA,
on: true,
timeName: "seconds",
alueDomainStart: 0,
valueDomainEnd: 127,
invert: true,
color: "#008080",
opacity: "0.1"
}
);
}
if (sostenutoEvents.length > 0) {
this.visualPane.createCurve(
sostenutoEvents,
{
curveType: Curve.AREA,
on: true,
timeName: "seconds",
alueDomainStart: 0,
valueDomainEnd: 127,
invert: true,
color: "#000080",
opacity: "0.1"
}
);
}
if (softPedalEvents.length > 0) {
this.visualPane.createCurve(
softPedalEvents,
{
curveType: Curve.AREA,
on: true,
timeName: "seconds",
alueDomainStart: 0,
valueDomainEnd: 127,
invert: true,
color: "#008000",
opacity: "0.1"
}
);
}
}
let zoomNotes = this.visualPane.generateNotes(noteEvents, {fill: "hsl(240, 30%, 25%)", stroke: "hsl(240, 30%, 25%)"});
this.zoomVisualPane.removeVisualType("notes");
this.zoomVisualPane.addVisual(zoomNotes);
let notes = this.visualPane.generateNotes(noteEvents, {fill: "hsl(240, 30%, 25%)", stroke: "hsl(240, 30%, 25%)"});
this.visualPane.removeVisualType("notes");
this.visualPane.addVisual(notes);
this.audioPane.setPlayer(new SynthesizerPlayer(this.audioContext, this.synthesizer, notes, this.audioEndTime));
resolve(true);
})
.catch(() => resolve(false));;
})
.catch(() => resolve(false));;
});
}
/**
* Initialize this aspect.
* @param {element} parentElement - The parent element.
* @param {number} parentWidth - The parent width.
* @param {number} parentHeight - The parent height.
*/
initialize(parentElement, parentWidth, parentHeight) {
this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
this.parentWidth = parentWidth;
this.parentHeight = parentHeight;
this.zoomHeight = 0.3*parentHeight;
this.readyPromise.then(() => {
this.midiDiv = this.parentElement.append("div")
.attr("class", "midiDiv");
this.midiInputDiv = this.midiDiv.append("div")
.attr("id", "midiInputDiv")
.style("margin-left", "2ch");
this.midiInputDiv.append("button")
.style("font-size", "1rem")
.style("margin-right", "2ch")
.text("Save Aspect to SVG")
.on("click", () => {
this.saveToSVG();
});
var midiFileInput = this.midiInputDiv.append("input")
.attr("type", "file")
.attr("accept", ".mid")
.style("display", "none")
.on("change", (event) => {
this.loadFile(event.target.files[0]);
this.erase();
this.initialize(this.frameDiv, this.parentWidth, this.parentHeight, this.audioEndTime);
this.draw();
});
this.midiInputDiv.append("button")
.style("font-size", "1rem")
.style("margin-right", "2ch")
.text("Select a local MIDI file")
.on("click", () => {
midiFileInput.node().click();
});
this.controlDiv = this.midiDiv.append("div")
.attr("class", "controlDiv")
.style("display", "flex")
.style("flex-direction", "row")
.style("margin-top", "2ex")
.style("margin-left", MARGIN.toString() + "px");
this.frameDiv = this.midiDiv.append("div")
.attr("class", "frameDiv");
this.audioPane.initializePanel(this.controlDiv);
this.zoomPane.initializePanel(this.controlDiv);
this.zoomTimeframe.initialize(this.frameDiv, parentWidth, this.zoomHeight, this.audioEndTime);
this.timeframe.initialize(this.frameDiv, parentWidth, parentHeight, this.audioEndTime);
});
}
/**
* Draw this aspect.
*/
draw() {
this.readyPromise.then(() => {
this.audioPane.drawPanel();
this.zoomPane.drawPanel();
this.zoomTimeframe.draw();
this.timeframe.draw();
});
}
/**
* Erase this aspect.
*/
erase() {
this.readyPromise.then(() => {
this.midiInputDiv.remove();
this.controlDiv.remove();
this.audioPane.erasePanel();
this.zoomPane.erasePanel();
this.timeframe.erase();
this.zoomTimeframe.erase();
});
}
/**
* When audio starts playing, set the AudioPane as the active pane.
*/
startPlaying() {
this.timeframe.setActivePane(this.audioPane);
}
/**
* When audio stops playing, set the VisualPane as the active pane.
*/
stopPlaying() {
this.timeframe.setActivePane(this.visualPane);
}
/**
* Zoom into the specified time range.
*/
zoomTarget(startTime, endTime) {
this.timeframe.zoomTimeRange(startTime, endTime);
this.timeframe.draw();
}
/**
* Clear all zoom-related values and draw the Timeframe.
*/
clearTarget() {
this.audioPane.playLineTime = 0;
this.audioPane.playLineX = 0;
this.timeframe.resetTimeRange();
this.timeframe.draw();
}
/**
* Save the visual in this aspect to an SVG file.
*/
saveToSVG() {
const serializer = new XMLSerializer();
const blob = new Blob([serializer.serializeToString(this.timeframe.timeFrameSVG.node().cloneNode(true))], {type: "image/svg+xml"});
const link = document.createElement("a");
link.download = "traces.svg";
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);
}
}
/**
* An JSONFileAspect folds visuals into a single simplified class.
*/
class JSONFileAspect {
/**
* Construct a new JSONFileAspect.
* @author Lawrence Fyfe
*/
constructor() {
this.parentElement;
this.parentWidth;
this.parentHeight;
this.dataViewDiv;
this.dataViewInputDiv;
this.controlDiv;
this.frameDiv;
this.readyPromise;
this.endTime = 0;
this.zoomTimeframe = new Timeframe({
marginTop: MARGIN,
marginRight: MARGIN,
marginBottom: 0,
marginLeft: MARGIN,
showTimeAxis: false
});
this.zoomVisualPane = new VisualPane();
this.zoomTimeframe.addPane(this.zoomVisualPane);
this.zoomPane = new ZoomPane();
this.zoomPane.onZoom = this.zoomTarget.bind(this);
this.zoomPane.onClear = this.clearTarget.bind(this);
this.zoomTimeframe.addPane(this.zoomPane);
this.timeframe = new Timeframe({
marginTop: 0,
marginRight: MARGIN,
marginBottom: MARGIN_BOTTOM,
marginLeft: MARGIN,
timeAxisTickCount: 10,
backgroundColor: "#ffffff"
});
this.visualPane = new VisualPane();
this.timeframe.addPane(this.visualPane);
}
/**
* Load a JSON file.
* @param {file} file - a file.
*/
loadFile(jsonArray) {
this.endTime = jsonArray[jsonArray.length - 1].time;
this.zoomVisualPane.removeVisualType("curve");
this.zoomVisualPane.createCurve(jsonArray, {type: "csv", name: "", color: "hsl(240, 30%, 25%)", opacity: "0.75", width: 1, on: true});
this.visualPane.removeVisualType("curve");
this.visualPane.createCurve(jsonArray, {type: "csv", name: "", color: "hsl(240, 30%, 25%)", on: true});
}
/**
* Initialize this aspect.
* @param {element} parentElement - The parent element.
* @param {number} parentWidth - The parent width.
* @param {number} parentHeight - The parent height.
*/
initialize(parentElement, parentWidth, parentHeight) {
this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
this.parentWidth = parentWidth;
this.parentHeight = parentHeight;
this.zoomHeight = 0.3*parentHeight;
this.dataViewDiv = this.parentElement.append("div")
.attr("class", "dataViewDiv");
this.dataViewInputDiv = this.dataViewDiv.append("div")
.attr("id", "dataViewInputDiv")
.style("margin-left", "2ch");
this.dataViewInputDiv.append("button")
.style("font-size", "1rem")
.style("margin-right", "2ch")
.text("Save Aspect to SVG")
.on("click", () => {
this.saveToSVG();
});
var dataViewInput = this.dataViewInputDiv.append("input")
.attr("type", "file")
.attr("accept", ".csv")
.style("display", "none")
.on("change", (event) => {
this.loadFile(event.target.files[0]);
d3.select("#this.dataViewInputDiv").remove();
this.erase();
this.initialize(this.frameDiv, this.parentWidth, this.parentHeight, this.endTime);
this.draw();
});
this.dataViewInputDiv.append("button")
.style("font-size", "1rem")
.style("margin-right", "2ch")
.text("Select a local JSON file")
.on("click", () => {
dataViewInput.node().click();
});
this.controlDiv = this.dataViewDiv.append("div")
.attr("class", "controlDiv")
.style("display", "flex")
.style("flex-direction", "row")
.style("margin-top", "2ex")
.style("margin-left", MARGIN.toString() + "px");
this.frameDiv = this.dataViewDiv.append("div")
.attr("class", "frameDiv");
this.zoomPane.initializePanel(this.controlDiv);
this.zoomTimeframe.initialize(this.frameDiv, parentWidth, this.zoomHeight, this.endTime);
this.timeframe.initialize(this.frameDiv, parentWidth, parentHeight, this.endTime);
}
/**
* Draw this aspect.
*/
draw() {
this.zoomPane.drawPanel();
this.zoomTimeframe.draw();
this.timeframe.draw();
}
/**
* Erase this aspect.
*/
erase() {
this.dataViewInputDiv.remove();
this.controlDiv.remove();
this.zoomPane.erasePanel();
this.zoomTimeframe.erase();
this.timeframe.erase();
}
/**
* Zoom into the specified time range.
*/
zoomTarget(startTime, endTime) {
this.timeframe.zoomTimeRange(startTime, endTime);
this.timeframe.draw();
}
/**
* Clear all zoom-related values and draw the Timeframe.
*/
clearTarget() {
this.timeframe.resetTimeRange();
this.timeframe.draw();
}
/**
* Save the visual in this aspect to an SVG file.
*/
saveToSVG() {
const serializer = new XMLSerializer();
const blob = new Blob([serializer.serializeToString(this.timeframe.timeFrameSVG.node().cloneNode(true))], {type: "image/svg+xml"});
const link = document.createElement("a");
link.download = "traces.svg";
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);
}
}
/**
* An CSVFileAspect folds visuals into a single simplified class.
*/
class CSVFileAspect {
/**
* Construct a new CSVFileAspect.
* @author Lawrence Fyfe
*/
constructor() {
this.hasHeader = true;
this.timeColumn = 0;
this.valueColumn = 1;
this.parentElement;
this.parentWidth;
this.parentHeight;
this.dataViewDiv;
this.dataViewInputDiv;
this.controlDiv;
this.frameDiv;
this.readyPromise;
this.endTime = 0;
this.zoomTimeframe = new Timeframe({
marginTop: MARGIN,
marginRight: MARGIN,
marginBottom: 0,
marginLeft: MARGIN,
showTimeAxis: false
});
this.zoomVisualPane = new VisualPane();
this.zoomTimeframe.addPane(this.zoomVisualPane);
this.zoomPane = new ZoomPane();
this.zoomPane.onZoom = this.zoomTarget.bind(this);
this.zoomPane.onClear = this.clearTarget.bind(this);
this.zoomTimeframe.addPane(this.zoomPane);
this.timeframe = new Timeframe({
marginTop: 0,
marginRight: MARGIN,
marginBottom: MARGIN_BOTTOM,
marginLeft: MARGIN,
timeAxisTickCount: 10,
backgroundColor: "#ffffff"
});
this.visualPane = new VisualPane();
this.timeframe.addPane(this.visualPane);
}
/**
* Load a CSV file.
* @param {file} file - a file.
*/
loadFile(file) {
this.readyPromise = file.text().then((textString) => {
let csv;
let csvData = [];
let startingRow = 0;
if (this.hasHeader) {
csv = d3.csvParse(textString);
startingRow = 1; // Start to convert after the header
}
else {
csv = d3.csvParseRows(textString);
}
for (let i = startingRow; i < csv.length; i++) {
let t = +csv[i][csv.columns[0]];
let v = +csv[i][csv.columns[1]];
if (!Number.isNaN(t) && !Number.isNaN(v)) {
csvData.push({time: t, value: v});
}
}
this.endTime = csvData[csvData.length-1].time;
this.zoomVisualPane.removeVisualType("curve");
this.zoomVisualPane.createCurve(csvData, {type: "csv", name: "", color: "hsl(240, 30%, 25%)", opacity: "0.75", width: 1, on: true});
this.visualPane.removeVisualType("curve");
this.visualPane.createCurve(csvData, {type: "csv", name: "", color: "hsl(240, 30%, 25%)", on: true});
});
}
/**
* Initialize this aspect.
* @param {element} parentElement - The parent element.
* @param {number} parentWidth - The parent width.
* @param {number} parentHeight - The parent height.
*/
initialize(parentElement, parentWidth, parentHeight) {
this.parentElement = parentElement instanceof d3.selection ? parentElement : d3.select(parentElement);
this.parentWidth = parentWidth;
this.parentHeight = parentHeight;
this.zoomHeight = 0.3*parentHeight;
this.readyPromise.then(() => {
this.dataViewDiv = this.parentElement.append("div")
.attr("class", "dataViewDiv");
this.dataViewInputDiv = this.dataViewDiv.append("div")
.attr("id", "dataViewInputDiv")
.style("margin-left", "2ch");
this.dataViewInputDiv.append("button")
.style("font-size", "1rem")
.style("margin-right", "2ch")
.text("Save Aspect to SVG")
.on("click", () => {
this.saveToSVG();
});
var dataViewInput = this.dataViewInputDiv.append("input")
.attr("type", "file")
.attr("accept", ".csv")
.style("display", "none")
.on("change", (event) => {
this.loadFile(event.target.files[0]);
d3.select("#this.dataViewInputDiv").remove();
this.erase();
this.initialize(this.frameDiv, this.parentWidth, this.parentHeight, this.endTime);
this.draw();
});
this.dataViewInputDiv.append("button")
.style("font-size", "1rem")
.style("margin-right", "2ch")
.text("Select a local CSV file")
.on("click", () => {
dataViewInput.node().click();
});
this.controlDiv = this.dataViewDiv.append("div")
.attr("class", "controlDiv")
.style("display", "flex")
.style("flex-direction", "row")
.style("margin-top", "2ex")
.style("margin-left", MARGIN.toString() + "px");
this.frameDiv = this.dataViewDiv.append("div")
.attr("class", "frameDiv");
this.zoomPane.initializePanel(this.controlDiv);
this.zoomTimeframe.initialize(this.frameDiv, parentWidth, this.zoomHeight, this.endTime);
this.timeframe.initialize(this.frameDiv, parentWidth, parentHeight, this.endTime);
});
}
/**
* Draw this aspect.
*/
draw() {
this.readyPromise.then(() => {
this.zoomPane.drawPanel();
this.zoomTimeframe.draw();
this.timeframe.draw();
});
}
/**
* Erase this aspect.
*/
erase() {
this.readyPromise.then(() => {
this.dataViewInputDiv.remove();
this.controlDiv.remove();
this.zoomPane.erasePanel();
this.zoomTimeframe.erase();
this.timeframe.erase();
});
}
/**
* Zoom into the specified time range.
*/
zoomTarget(startTime, endTime) {
this.timeframe.zoomTimeRange(startTime, endTime);
this.timeframe.draw();
}
/**
* Clear all zoom-related values and draw the Timeframe.
*/
clearTarget() {
this.timeframe.resetTimeRange();
this.timeframe.draw();
}
/**
* Save the visual in this aspect to an SVG file.
*/
saveToSVG() {
const serializer = new XMLSerializer();
const blob = new Blob([serializer.serializeToString(this.timeframe.timeFrameSVG.node().cloneNode(true))], {type: "image/svg+xml"});
const link = document.createElement("a");
link.download = "traces.svg";
link.href = URL.createObjectURL(blob);
link.click();
URL.revokeObjectURL(link.href);
}
}