adblockradio/post-processing.js

637 lines
24 KiB
JavaScript

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Copyright (c) 2018 Alexandre Storelli
"use strict";
const { log } = require("abr-log")("post-processing");
const PredictorFile = require("./predictor-file.js");
const { Transform, Readable } = require("stream");
const fs = require("fs-extra");
const { checkModelUpdates, checkMetadataUpdates } = require("./check-updates.js");
const consts = {
WLARRAY: ["0-ads", "1-speech", "2-music", "3-jingles"],
UNSURE: "unsure",
CACHE_MAX_LEN: 50,
MOV_AVG_WEIGHTS: [
{ "weights": [0.05, 0.05, 0.05, 0.10, 0.10, 0.15, 0.20, 0.30, 0.80, 1.00], "sum": 2.80 }, // r=0 same as ideal r=1 for very short buffers. so 1 step lag
{ "weights": [0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.45, 0.70, 0.80, 1.00], "sum": 4.30 }, // r=1 same as ideal r=2 for short buffers. so 1 step lag
{ "weights": [0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.45, 0.70, 0.80, 1.00], "sum": 4.30 }, // r=2
{ "weights": [0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90, 1.00], "sum": 5.50 }, // r=3
{ "weights": [0.25, 0.35, 0.50, 0.70, 0.90, 1.00, 1.00, 0.80, 0.70, 0.20], "sum": 6.20 } // r=4
],
ML_CONFIDENCE_THRESHOLD: 0.65,
HOTLIST_CONFIDENCE_THRESHOLD: 0.5,
FINAL_CONFIDENCE_THRESHOLD: 0.40,
MINIMUM_BUFFER: 2, // in seconds.
// Some radios have a very small buffer, down to zero.
// But browsers such as Firefox and Chrome only start playing after a 2 second buffer.
// So we artificially delay the predictions.
// VLC player, however, plays without such delay.
DOWNSTREAM_LATENCY: 500 // in milliseconds. broadcast the prediction result N ms before it should be applied by the players of the end users.
}
class PostProcessor extends Transform {
constructor(config) {
super({ writableObjectMode: true, readableObjectMode: true });
this.cache = [];
this._postProcessing = this._postProcessing.bind(this);
this.slotCounter = 0;
this.metadata = null;
this.metadataValidUntil = null;
this.streamInfo = null;
this.startTime = +new Date();
this.config = config;
//log.debug("fileMode=" + this.config.fileMode);
}
_write(obj, enc, next) {
if (!this.cache[0]) this._newCacheSlot(0);
switch (obj.type) {
case "audio": // only in stream analysis mode
if (obj.newSegment && this.cache[0] && this.cache[0].audio && this.cache[0].audio.length > 0) {
if (this.config.verbose) log.info("in: audio => " + this.cache[0].audio.length + " bytes, tBuf=" + obj.tBuffer.toFixed(2) + "s");
this._newCacheSlot(Math.max(obj.tBuffer, consts.MINIMUM_BUFFER));
}
this.cache[0].audio = this.cache[0].audio ? Buffer.concat([this.cache[0].audio, obj.data]) : obj.data;
this.cache[0].metadataPath = obj.metadataPath;
break;
case "fileChunk": // only in file analysis mode
this.cache[0].audio = obj.data;
this.cache[0].metadataPath = obj.metadataPath;
this.cache[0].tStart = obj.tStart; // in ms
this.cache[0].tEnd = obj.tEnd; // in ms
if (this.config.verbose) log.info("in: fileChunk => " + this.cache[0].audio.length + " bytes, tStart=" + (obj.tStart / 1000).toFixed(2) + "s");
this._newCacheSlot();
break;
case "ml":
if (this.config.verbose) {
log.info("in: ml => type=" + consts.WLARRAY[obj.data.type] + " confidence=" + obj.data.confidence.toFixed(2) +
" softmaxraw=" + obj.data.softmaxraw.map(e => e.toFixed(2)) + " confidence=" + obj.data.confidence.toFixed(2));
}
if (this.cache[0].ml) log.warn("overwriting ml cache data!")
this.cache[0].ml = obj.data;
this.cache[0].gain = obj.data.gain;
break;
case "hotlist":
if (this.config.verbose) {
log.info("in: hotlist =>" +
" matches/totR/totM=" + obj.data.matchesSync + "/" + obj.data.fingersCountRef + "/" + obj.data.fingersCountMeasurements +
" tAvg/tStd/duration=" + obj.data.tRefAvg + "/" + obj.data.tRefStd + "/" + obj.data.durationRef +
" class=" + consts.WLARRAY[obj.data.class]);
}
if (this.cache[0].hotlist) log.warn("overwriting hotlist cache data!");
this.cache[0].hotlist = obj.data;
break;
case "title":
if (this.config.verbose) log.info("in: title => " + JSON.stringify(obj.data));
this.metadata = obj.data;
// validity: not setting a validity or setting it to zero lead to infinite validity.
this.metadataValidUntil = obj.validity ? (+new Date() + obj.validity * 1000 * 2) : Infinity;
break;
case "dlinfo":
if (this.config.verbose) log.info("in: dlinfo => " + JSON.stringify(obj.data));
this.streamInfo = {
url: obj.data.url,
bitrate: obj.data.bitrate,
favicon: obj.data.favicon,
homepage: obj.data.homepage,
audioExt: obj.data.ext
}
break;
default:
log.warn(JSON.stringify(obj.data));
}
next();
}
_newCacheSlot(tBuffer) {
if (this.config.verbose) log.debug("---------------------");
const now = +new Date();
this.slotCounter++;
this.cache.unshift({ ts: null, audio: null, ml: null, hotlist: null, tBuf: tBuffer, n: this.slotCounter });
if (this.cache[1]) {
this.cache[1].ts = now;
} else { // happens only at first startup.
this.cache[0].ts = now;
}
if (this.config.fileMode) {
if (this.cache.length >= 5) {
this._postProcessing(this.cache[4].ts);
}
} else {
// schedule the postprocessing for this slot, according to the buffer available.
// "now" is used as a reference for _postProcessing, so it knows which slot to process
// postProcessing happens 500ms before audio playback, so that clients / players have time to act.
setTimeout(this._postProcessing, tBuffer * 1000 - consts.DOWNSTREAM_LATENCY, now);
}
if (this.cache.length > consts.CACHE_MAX_LEN) this.cache.pop();
}
_final(next) { // only in file mode, because radio streams "never" end
log.info('flushing post processor cache');
for (let i=3; i>=1; i--) {
if (this.cache[i]) this._postProcessing(this.cache[i].ts);
}
next();
}
// average the softmax vectors over time, to smooth the results.
// the average uses a window function in consts.MOV_AVG_WEIGHTS
//
// parameters: i: index in this.cache[] being analyzed
// prop: either 'ml' or 'hotlist', the softmax to consider
// availableSlotsPast: number of time slots to look at in the past
// availableSlotsFuture: number of time slots to look at in the future
_movAvg(i, prop, availableSlotsPast, availableSlotsFuture) {
let movAvg = new Array(4);
let iMaxMovAvg = 0;
let maxMovAvg = 0;
let localMax = 0;
let iLocalMax = 0;
for (let ic = 0; ic < movAvg.length; ic++) {
movAvg[ic] = 0;
let sum = 0;
for (let j = 0; j <= availableSlotsPast + availableSlotsFuture; j++) {
/*if (ic == 0) {
log.debug("i=" + i + " cacheLen=" + this.cache.length + " availPast=" + availableSlotsPast +
" availFut=" + availableSlotsFuture + " j=" + j + " ml?=" + !!(this.cache[i + availableSlotsPast - j].ml));
}*/
if (this.cache[i + availableSlotsPast - j][prop] && this.cache[i + availableSlotsPast - j][prop].softmaxraw) {
if (ic == 0 && isNaN(this.cache[i + availableSlotsPast - j][prop].softmaxraw[ic])) {
log.warn(this.config.country + "_" + this.config.name + " _movAvg this.cache[i + availableSlotsPast - j]." + prop + ".softmaxraw[ic] is NaN." +
" i=" + i + " availableSlotsPast=" + availableSlotsPast + " j=" + j + " ic=" + ic);
}
if (ic == 0 && isNaN(consts.MOV_AVG_WEIGHTS[availableSlotsFuture].weights[j])) {
log.warn(this.config.country + "_" + this.config.name + " _movAvg consts.MOV_AVG_WEIGHTS[availableSlotsFuture].weights[j] is NaN." +
" availableSlotsFuture=" + availableSlotsFuture + " j=" + j);
}
movAvg[ic] += this.cache[i + availableSlotsPast - j][prop].softmaxraw[ic] * consts.MOV_AVG_WEIGHTS[availableSlotsFuture].weights[j];
sum += consts.MOV_AVG_WEIGHTS[availableSlotsFuture].weights[j];
}
}
if (!sum) log.warn(this.config.country + "_" + this.config.name + " _movAvg: sum is zero. i=" + i + " prop=" + prop + " ic=" + ic + " past=" + availableSlotsPast + " future=" + availableSlotsFuture);
movAvg[ic] = sum ? (movAvg[ic] / sum) : null;
if (movAvg[ic] && movAvg[ic] > maxMovAvg) {
maxMovAvg = movAvg[ic];
iMaxMovAvg = ic;
}
if (this.cache[i][prop] && this.cache[i][prop].softmaxraw && this.cache[i][prop].softmaxraw[ic] > localMax) {
localMax = this.cache[i][prop].softmaxraw[ic];
iLocalMax = ic;
}
}
//log.debug("movAvg=" + JSON.stringify(movAvg));
return {
movAvg: movAvg, // average softmax
maxMovAvg: maxMovAvg, // max value of the average softmax
iMaxMovAvg: iMaxMovAvg, // index of that max value
localMax: localMax, // max value of the softmax at the time considered
iLocalMax: iLocalMax, // index of that max value
};
}
_postProcessing(tsRef) {
if (this.ended) return log.warn(this.config.country + "_" + this.config.name + ' abort _postProcessing event because stream is ended.');
const i = this.cache.map(e => e.ts).indexOf(tsRef);
if (i < 0) return log.warn(this.config.country + "_" + this.config.name + " _postProcessing: cache item not found for tsRef=" + tsRef);
const availableSlotsFuture = Math.min(i, 4); // consts.MOV_AVG_WEIGHTS supports up to 4 slots in the future.
const availableSlotsPast = Math.min(this.cache.length - 1 - i, consts.MOV_AVG_WEIGHTS[0].weights.length - availableSlotsFuture - 1); // verification: first slot ever (i=0, cache.len=1) leads to zero past slots.
// smoothing over time of ML predictions.
let mlOutput = null;
if (this.cache[i].ml) {
const { movAvg, maxMovAvg, iMaxMovAvg } = this._movAvg(i, "ml", availableSlotsPast, availableSlotsFuture);
// tell if the ML prediction is unsure. Purely informative, as the threshold does not affect the final class
const mlConfident = maxMovAvg > consts.ML_CONFIDENCE_THRESHOLD;
//log.debug("out: movAvg: slot n=" + this.cache[i].n + " i=" + i + " movAvg=" + movAvg.map(e => +e.toFixed(3)) + " confident=" + mlConfident);
mlOutput = {
class: mlConfident ? consts.WLARRAY[iMaxMovAvg] : consts.UNSURE,
softmaxraw: this.cache[i].ml && this.cache[i].ml.softmaxraw.map(e => +e.toFixed(3)),
softmax: movAvg.map(e => +e.toFixed(3)),
slotsFuture: availableSlotsFuture,
slotsPast: availableSlotsPast
}
}
// smoothing over time of hotlist detections
let hotlistOutput = null;
if (this.cache[i].hotlist) {
const { movAvg, maxMovAvg, iMaxMovAvg, localMax, iLocalMax } = this._movAvg(i, "hotlist", availableSlotsPast, availableSlotsFuture);
hotlistOutput = Object.assign({}, this.cache[i].hotlist);
hotlistOutput.softmaxraw = hotlistOutput.softmaxraw.map(e => +e.toFixed(3));
hotlistOutput.softmax = movAvg.map(e => +e.toFixed(3));
// tell if the hotlist prediction is unsure. Purely informative, as the threshold does not affect the final class
const hlConfident = maxMovAvg > consts.HOTLIST_CONFIDENCE_THRESHOLD;
hotlistOutput.class = hlConfident ? consts.WLARRAY[this.cache[i].hotlist.class] : consts.UNSURE;
// only give the file when it is a local detection. otherwise, could give wrong info
// if just after a detection, nothing locally detected but movAvg still above threshold.
const localHlConfident = localMax > consts.HOTLIST_CONFIDENCE_THRESHOLD;
hotlistOutput.file = localHlConfident ? hotlistOutput.file : null;
}
// synthesis of predictions. Average the softmax vectors.
let finalSoftmax = new Array(4).fill(0);
let iFinalClass = 0, maxSoftmax = 0;
for (let i=0; i<4; i++) {
let count = 0;
if (mlOutput) {
finalSoftmax[i] += mlOutput.softmax[i];
count += 1;
}
if (hotlistOutput) {
finalSoftmax[i] += hotlistOutput.softmax[i];
count += 1;
}
if (count) finalSoftmax[i] /= count;
if (finalSoftmax[i] > maxSoftmax) {
maxSoftmax = finalSoftmax[i];
iFinalClass = i;
}
}
// final class has an hysteresis behaviour
const finalClass = maxSoftmax > consts.FINAL_CONFIDENCE_THRESHOLD ? consts.WLARRAY[iFinalClass] : consts.UNSURE;
// final output
let out = {
gain: this.cache[i].gain && +this.cache[i].gain.toFixed(2),
ml: mlOutput,
hotlist: hotlistOutput,
class: finalClass,
softmax: finalSoftmax,
metadataPath: this.cache[i].metadataPath,
}
if (this.config.fileMode) {
Object.assign(out, { // results specific to file analysis mode
tStart: this.cache[i].tStart,
tEnd: this.cache[i].tEnd,
//playTime: this.config.records ? +new Date(this.cache[i].metadataPath.slice(-24)) : undefined,
});
} else {
//log.debug("streamInfo=" + JSON.stringify(this.streamInfo, null, "\t"));
Object.assign(out, { // results specific to stream analysis mode
audio: this.cache[i].audio,
predictorStartTime: this.startTime,
metadata: +new Date() < this.metadataValidUntil ? this.metadata : null,
streamInfo: this.streamInfo,
playTime: Math.round(tsRef + this.cache[i].tBuf * 1000),
tBuffer: +this.cache[i].tBuf.toFixed(2),
});
}
//log.debug(JSON.stringify(Object.assign(out, { audio: undefined }), null, "\t"));
try {
this.push(out);
} catch (e) {
log.warn("could not push. err=" + e);
}
}
}
class Analyser extends Readable {
constructor(options) {
super({ objectMode: true });
this.country = options.country;
this.name = options.name;
if (!this.country || !this.name) {
return log.error("Analyser needs to be constructed with: country (string) and name (string)");
}
const defaultModelPath = process.cwd() + '/model';
const defaultModelFile = this.country + '_' + this.name + '/model.keras';
const defaultHotlistFile = this.country + '_' + this.name + '/hotlist.sqlite';
// default module options
this.config = {
saveMetadata: true, // save a JSON with predictions (saveDuration intervals)
verbose: false,
file: null, // analyse a file instead of a HTTP stream. will not download stream
records: null, // analyse a series of previous records (relative paths). will not download stream
modelPath: defaultModelPath, // directory where ML models and hotlist DBs are stored
modelFile: defaultModelFile, // TODO
hotlistFile: defaultHotlistFile, // TODO
modelUpdates: true, // periodically fetch ML and hotlist models and refresh predictors
modelUpdateInterval: 60 // update model files every N minutes
}
// optional custom config
Object.assign(this.config, options.config);
this.postProcessor = new PostProcessor({
country: this.country,
name: this.name,
verbose: this.config.verbose,
fileMode: !!this.config.file || !!this.config.records,
});
const self = this;
this.postProcessor.on("data", function(obj) {
if (!self.config.file && !self.config.records && !obj.audio) {
log.warn("empty audio! " + JSON.stringify(obj));
}
const metadataPath = obj.metadataPath;
Object.assign(obj, {
audioLen: obj.audio ? obj.audio.length : undefined,
metadataPath: undefined
});
self.push({ liveResult: obj });
if (!self.config.saveMetadata) return;
if (!metadataPath) {
log.warn("did not save metadata file, because missing metadataPath parameter");
} else if (self.config.file) {
self.data = self.data || { predictions: [], country: self.country, name: self.name };
self.data.predictions.push(obj);
} else if (self.config.records) {
self.saveMetadata(obj, metadataPath);
} else {
self.saveMetadata(obj, metadataPath);
}
});
this.postProcessor.on("end", function() {
log.info("postProcessor ended");
if (!self.data) return self.push(null);
if (self.config.file) {
self.mergeClassBlocks(self.data, function(blocksCleaned) {
self.push({ blocksCleaned: blocksCleaned });
self.push(null);
});
} else if (self.config.records) {
self.push(null);
}
});
if (this.config.file) {
// analysis of a single recording
// suitable for e.g. podcasts.
// output a file containing time stamps of transitions.
if (fs.existsSync(process.cwd() + "/" + this.config.file + ".json")) fs.unlinkSync(process.cwd() + "/" + this.config.file + ".json");
this.predictor = new PredictorFile({
country: this.country,
name: this.name,
file: this.config.file,
modelFile: this.config.modelPath + '/' + this.config.modelFile,
hotlistFile: this.config.modelPath + '/' + this.config.hotlistFile,
config: this.config,
listener: this.postProcessor
});
} else if (this.config.records) {
// analysis of an array of recordings
// suitable for asynchronous analysis of chunks of live streams.
// outputs a complete analysis report for each audio chunk.
this.offlinets = +new Date();
this.predictor = new PredictorFile({
country: this.country,
name: this.name,
records: this.config.records,
modelFile: this.config.modelPath + '/' + this.config.modelFile,
hotlistFile: this.config.modelPath + '/' + this.config.hotlistFile,
config: this.config,
listener: this.postProcessor,
verbose: true,
});
} else {
// live stream analysis
// emits results with the Readable interface
(async function() {
// download and/or update models at startup
if (self.config.modelUpdates) {
await checkModelUpdates({
localPath: self.config.modelPath,
files: [
{ file: self.config.modelFile, tar: true },
{ file: self.config.hotlistFile, tar: true },
]
});
} else {
log.info(self.country + '_' + self.name + ' model updates are disabled');
}
await checkMetadataUpdates();
// we require only when metadata scraper is downloaded
const Predictor = require('./predictor.js');
// download and/or update metadata scraper at startup
self.predictor = new Predictor({
country: self.country,
name: self.name,
modelFile: self.config.modelPath + '/' + self.config.modelFile,
hotlistFile: self.config.modelPath + '/' + self.config.hotlistFile,
config: self.config,
listener: self.postProcessor
});
self.modelUpdatesInterval = setInterval(function() {
if (self.config.modelUpdates) {
checkModelUpdates({
localPath: self.config.localPath,
files: [
{ file: self.config.modelFile, tar: true, callback: self.predictor.refreshPredictorMl },
{ file: self.config.hotlistFile, tar: true, callback: self.predictor.refreshPredictorHotlist },
]
});
}
checkMetadataUpdates(self.predictor.refreshMetadata);
}, self.config.modelUpdateInterval * 60000);
})();
}
this.refreshPredictorHotlist = this.refreshPredictorHotlist.bind(this);
this.refreshPredictorMl = this.refreshPredictorMl.bind(this);
this.stopDl = this.stopDl.bind(this);
/*
// only to test mergeClassBlocks method
fs.readFile(this.config.file + ".json", function(err, data) {
data = JSON.parse(data);
self.mergeClassBlocks(data, function(blocksCleaned) {
self.push(blocksCleaned);
self.push(null); // end the Analyser Readable stream
});
});
*/
}
saveMetadata(obj, path) {
const self = this;
fs.readFile(path, function(err, readData) {
let data = { predictions: [] };
if (!err) {
try {
data = JSON.parse(readData);
} catch (e) {
log.warn("metadataPath read parsing err=" + JSON.stringify(e));
}
} else if (err && err.code !== "ENOENT") {
log.debug("metadataPath read err=" + JSON.stringify(err) + ". erase any previous metadata info");
}
let outputData = Object.assign({}, obj);
// extract redundant info: no need to repeat it in predictions array
// if the title metadata changes, only the last one is saved
data.metadata = outputData.metadata || data.metadata;
data.streamInfo = outputData.streamInfo || data.streamInfo;
data.predictorStartTime = outputData.predictorStartTime || data.predictorStartTime;
data.country = self.country;
data.name = self.name;
Object.assign(outputData, {
audio: undefined,
metadata: undefined,
streamInfo: undefined,
predictorStartTime: undefined
});
if (data.offlinets !== self.offlinets && self.config.records) {
data.predictions = [];
data.offlinets = self.offlinets;
}
data.predictions.push(outputData);
fs.writeFile(path, JSON.stringify(data, null, "\t"), function(err) {
if (err) log.warn("metadata write err=" + JSON.stringify(err));
});
});
}
// used in the context of file analysis
// merge contiguous data with identical class to present a more compact result.
mergeClassBlocks(data, callback) {
//const self = this;
const path = this.config.file + ".json";
data.blocksRaw = [];
for (let i=0; i<data.predictions.length; i++) {
const ml = data.blocksRaw.length
if (!ml || data.blocksRaw[ml-1].class !== data.predictions[i].class) {
data.blocksRaw.push({
class: data.predictions[i].class,
tStart: data.predictions[i].tStart,
tEnd: data.predictions[i].tEnd
});
} else { // same class as the previous one.
data.blocksRaw[ml-1].tEnd = data.predictions[i].tEnd;
}
}
// convert short blocks to "unsure" and merge any sequential "unsure" blocks together
data.blocksCoarse = data.blocksRaw.slice().map(b => Object.assign({}, b));
for (let i=data.blocksCoarse.length-1; i>=0; i--) {
//const l = data.blocksCoarse.length;
const delta = data.blocksCoarse[i].tEnd - data.blocksCoarse[i].tStart;
if (delta < 5000) {
data.blocksCoarse[i].class = "unsure";
}
}
for (let i=data.blocksCoarse.length-1; i>=1; i--) {
if (data.blocksCoarse[i-1].class === "unsure" && data.blocksCoarse[i].class === "unsure") {
data.blocksCoarse[i-1].tEnd = data.blocksCoarse[i].tEnd;
data.blocksCoarse.splice(i, 1);
}
}
// remove unsure blocks, assume neighbors are right.
data.blocksCleaned = data.blocksCoarse.slice().map(b => Object.assign({}, b));
if (data.blocksCleaned.length >= 2 && data.blocksCleaned[0].class === "unsure") { // remove first if unsure
data.blocksCleaned[1].tStart = data.blocksCleaned[0].tStart;
data.blocksCleaned.splice(0, 1);
}
if (data.blocksCleaned.length >= 2 && data.blocksCleaned[data.blocksCleaned.length-1].class === "unsure") { // remove last if unsure
data.blocksCleaned[data.blocksCleaned.length-2].tEnd = data.blocksCleaned[data.blocksCleaned.length-1].tEnd;
data.blocksCleaned.splice(data.blocksCleaned.length-1, 1);
}
for (let i=data.blocksCleaned.length-2; i>=1; i--) { // remove others if unsure
if (data.blocksCleaned[i].class !== "unsure") continue;
if (data.blocksCleaned[i+1].class !== data.blocksCleaned[i-1].class) { // unsure between two different blocks
const delta = data.blocksCleaned[i].tEnd - data.blocksCleaned[i].tStart;
data.blocksCleaned[i+1].tStart -= delta / 2;
data.blocksCleaned[i-1].tEnd += delta / 2;
data.blocksCleaned.splice(i, 1);
} else { // unsure between two identical blocks. remove it
data.blocksCleaned[i-1].tEnd = data.blocksCleaned[i+1].tEnd;
data.blocksCleaned.splice(i, 2);
}
}
fs.writeFile(path, JSON.stringify(data, null, "\t"), function(err) {
if (err) log.warn("metadata write err=" + JSON.stringify(err));
log.info("detailed analysis results have been written to " + path);
callback(data.blocksCleaned);
});
}
refreshPredictorMl() {
this.predictor.refreshPredictorMl();
}
refreshPredictorHotlist() {
this.predictor.refreshPredictorHotlist();
}
refreshMetadata() {
this.predictor.refreshMetadata();
}
stopDl() {
// TODO
if (this.predictor) this.predictor.stop();
if (this.postProcessor) {
this.postProcessor.ended = true;
this.postProcessor.end();
}
if (this.modelUpdatesInterval) clearInterval(this.modelUpdatesInterval);
this.push(null);
}
_read() {
// nothing
}
}
exports.PostProcessor = PostProcessor
exports.Analyser = Analyser;