+
- zoom_in
+ zoom_in
zoom_out
+ style="max-width: 70px">zoom_out
diff --git a/js/guano.js b/js/guano.js
deleted file mode 100644
index e5f142b9..00000000
--- a/js/guano.js
+++ /dev/null
@@ -1,117 +0,0 @@
-////////// GUANO Support /////////////
-
-const fs = require('node:fs');
-
-/**
- * Extract GUANO metadata from a WAV file, without reading the entire file into memory.
- * @param {string} filePath - Path to the WAV file.
- * @returns {Promise
} - The extracted GUANO metadata or null if not found.
- */
-function extractGuanoMetadata(filePath) {
- return new Promise((resolve, reject) => {
- // Open the file
- fs.open(filePath, 'r', (err, fd) => {
- if (err) return reject(err);
-
- const buffer = Buffer.alloc(12); // Initial buffer for RIFF header and first chunk header
-
- // Read the RIFF header (12 bytes)
- fs.read(fd, buffer, 0, 12, 0, (err) => {
- if (err) return reject(err);
-
- const chunkId = buffer.toString('utf-8', 0, 4); // Should be "RIFF"
- const format = buffer.toString('utf-8', 8, 12); // Should be "WAVE"
-
- if (chunkId !== 'RIFF' || format !== 'WAVE') {
- return reject(new Error('Invalid WAV file'));
- }
-
- let currentOffset = 12; // Start after the RIFF header
-
- // Function to read the next chunk header
- function readNextChunk() {
- const chunkHeaderBuffer = Buffer.alloc(8); // 8 bytes for chunk ID and size
- fs.read(fd, chunkHeaderBuffer, 0, 8, currentOffset, (err) => {
- if (err) return reject(err);
-
- const chunkId = chunkHeaderBuffer.toString('utf-8', 0, 4); // Chunk ID
- const chunkSize = chunkHeaderBuffer.readUInt32LE(4); // Chunk size
- if (chunkSize === 0) return resolve(null) // No GUANO found
-
- currentOffset += 8; // Move past the chunk header
-
- if (chunkId === 'guan') {
- // GUANO chunk found, read its content
- const guanoBuffer = Buffer.alloc(chunkSize);
- fs.read(fd, guanoBuffer, 0, chunkSize, currentOffset, (err) => {
- if (err) return reject(err);
-
- // GUANO data is UTF-8 encoded
- const guanoText = guanoBuffer.toString('utf-8');
- const guanoMetadata = _parseGuanoText(guanoText);
- resolve(guanoMetadata);
-
- fs.close(fd, () => {}); // Close the file descriptor
- });
- } else if (chunkId === 'data') {
- // Skip over the data chunk (just move the offset)
- currentOffset += chunkSize;
- // Handle padding if chunkSize is odd
- if (chunkSize % 2 !== 0) currentOffset += 1;
- readNextChunk(); // Continue reading after skipping the data chunk
- } else {
- // Skip over any other chunk
- currentOffset += chunkSize;
- // Handle padding if chunkSize is odd
- if (chunkSize % 2 !== 0) currentOffset += 1;
- readNextChunk(); // Continue reading
- }
- });
- }
-
- // Start reading chunks after the RIFF header
- readNextChunk();
- });
- });
- });
-}
-
-
-/**
- * Helper function to parse GUANO text into key-value pairs
- * @param {string} guanoText - GUANO text data
- * @returns {object} Parsed GUANO metadata
- */
-function _parseGuanoText(guanoText) {
- const guanoMetadata = {};
- // According to the GUANO Spec, the note field can contain escaped newline characters '\\n'
- // So, we'll substitute a placeholder to avoid conflicts
- const _tempGuano = guanoText.replaceAll('\\n', '\uFFFF');
- const lines = _tempGuano.split('\n');
-
- lines.forEach(line => {
- const colonIndex = line.indexOf(':');
- if (colonIndex !== -1) {
- const key = line.slice(0, colonIndex).trim();
- // Replace the placeholder with '\n'
- const value = line.slice(colonIndex + 1).trim().replaceAll('\uFFFF', '\n');
-
- try {
- // Attempt to parse JSON-like values
- if ((value.startsWith('[') && value.endsWith(']')) ||
- (value.startsWith('{') && value.endsWith('}'))) {
- guanoMetadata[key] = JSON.parse(value);
- } else {
- guanoMetadata[key] = value;
- }
- } catch {
- guanoMetadata[key] = value;
- }
- }
- });
-
- return guanoMetadata;
-}
-
-
-export {extractGuanoMetadata}
\ No newline at end of file
diff --git a/js/metadata.js b/js/metadata.js
new file mode 100644
index 00000000..3ec6a5df
--- /dev/null
+++ b/js/metadata.js
@@ -0,0 +1,143 @@
+////////// GUANO Support /////////////
+
+const fs = require('node:fs');
+
+/**
+ * Extract metadata from a WAV file, without reading the entire file into memory.
+ * @param {string} filePath - Path to the WAV file.
+ * @returns {Promise} - The extracted metadata or null if not found.
+ */
+function extractWaveMetadata(filePath) {
+ let metadata = {}
+ return new Promise((resolve, reject) => {
+ // Open the file
+ fs.open(filePath, 'r', (err, fd) => {
+ if (err) return reject(err);
+
+ const buffer = Buffer.alloc(12); // Initial buffer for RIFF header and first chunk header
+
+ // Read the RIFF header (12 bytes)
+ fs.read(fd, buffer, 0, 12, 0, (err) => {
+ if (err) return reject(err);
+
+ const chunkId = buffer.toString('utf-8', 0, 4); // Should be "RIFF"
+ const format = buffer.toString('utf-8', 8, 12); // Should be "WAVE"
+
+ if (chunkId !== 'RIFF' || format !== 'WAVE') {
+ return reject(new Error('Invalid WAV file: ', filePath));
+ }
+
+ let currentOffset = 12; // Start after the RIFF header
+
+ // Function to read the next chunk header
+ function readNextChunk() {
+ const chunkHeaderBuffer = Buffer.alloc(8); // 8 bytes for chunk ID and size
+ fs.read(fd, chunkHeaderBuffer, 0, 8, currentOffset, (err) => {
+ if (err) return reject(err);
+
+ const chunkId = chunkHeaderBuffer.toString('utf-8', 0, 4); // Chunk ID
+ const chunkSize = chunkHeaderBuffer.readUInt32LE(4); // Chunk size
+ if (chunkSize === 0) {
+ fs.close(fd, () => {}); // Close the file descriptor
+ resolve(metadata) // No GUANO found
+ }
+
+ currentOffset += 8; // Move past the chunk header
+
+ if (chunkId === 'guan') {
+ // GUANO chunk found, read its content
+ const guanoBuffer = Buffer.alloc(chunkSize);
+ fs.read(fd, guanoBuffer, 0, chunkSize, currentOffset, (err) => {
+ if (err) return reject(err);
+
+ // GUANO data is UTF-8 encoded
+ const guanoText = guanoBuffer.toString('utf-8');
+ const guano = _parseMetadataText(guanoText);
+ metadata['guano'] = guano;
+
+ });
+ } else if (chunkId === 'bext') {
+ // GUANO chunk found, read its content
+ const bextBuffer = Buffer.alloc(chunkSize);
+ fs.read(fd, bextBuffer, 0, chunkSize, currentOffset, (err) => {
+ if (err) return reject(err);
+ const bext = {
+ Description: bextBuffer.toString('ascii', 0, 256).replaceAll('\\u000', ''),
+ Originator: bextBuffer.toString('ascii', 256, 288).replaceAll('\\u000', ''),
+ OriginatorReference: bextBuffer.toString('ascii', 288, 320).replaceAll('\\u000', ''),
+ OriginationDate: bextBuffer.toString('ascii', 320, 330).replaceAll('\\u000', ''),
+ OriginationTime: bextBuffer.toString('ascii', 330, 338).trim(),
+ TimeReferenceLow: bextBuffer.readUInt32LE(338),
+ TimeReferenceHigh: bextBuffer.readUInt32LE(342),
+ Version: bextBuffer.readUInt16LE(346),
+ UMID: bextBuffer.subarray(348, 380).toString('hex').trim(),
+ LoudnessValue: bextBuffer.readUInt16LE(380),
+ LoudnessRange: bextBuffer.readUInt16LE(382),
+ MaxTruePeakLevel: bextBuffer.readUInt16LE(384),
+ MaxMomentaryLoudness: bextBuffer.readUInt16LE(386),
+ MaxShortTermLoudness: bextBuffer.readUInt16LE(388)
+ };
+ // bext data is UTF-8 encoded
+ const bextText = bextBuffer.subarray(392, chunkSize).toString('utf-8');
+ const bextMetadata = _parseMetadataText(bextText);
+ metadata['bext'] = {...bext, ...bextMetadata};
+ // Strip empty or null keys
+ for (let key in metadata['bext']) {
+ if (['', 0].includes(metadata.bext[key])
+ || /^0*$/.test(metadata.bext[key])
+ || /^\u0000*$/.test(metadata.bext[key])) {
+ delete metadata.bext[key];
+ }
+ }
+ });
+ }
+ if (chunkSize % 2 !== 0) currentOffset += 1;
+ currentOffset += chunkSize
+ readNextChunk(); // Continue reading after skipping the data chunk
+ });
+ }
+ // Start reading chunks after the RIFF header
+ readNextChunk();
+ });
+ });
+ });
+}
+
+
+/**
+ * Helper function to parse GUANO text into key-value pairs
+ * @param {string} guanoText - GUANO text data
+ * @returns {object} Parsed GUANO metadata
+ */
+function _parseMetadataText(text) {
+ const metadata = {};
+ // According to the GUANO Spec, the note field can contain escaped newline characters '\\n'
+ // So, we'll substitute a placeholder to avoid conflicts
+ const _tempGuano = text.replaceAll('\\n', '\uFFFF');
+ const lines = _tempGuano.split('\n');
+
+ lines.forEach(line => {
+ const colonIndex = line.indexOf(':');
+ if (colonIndex !== -1) {
+ const key = line.slice(0, colonIndex).trim();
+ // Replace the placeholder with '\n'
+ const value = line.slice(colonIndex + 1).trim().replaceAll('\uFFFF', '\n');
+
+ try {
+ // Attempt to parse JSON-like values
+ if ((value.startsWith('[') && value.endsWith(']')) ||
+ (value.startsWith('{') && value.endsWith('}'))) {
+ metadata[key] = JSON.parse(value);
+ } else {
+ metadata[key] = value;
+ }
+ } catch {
+ metadata[key] = value;
+ }
+ }
+ });
+
+ return metadata;
+}
+
+export {extractWaveMetadata}
\ No newline at end of file
diff --git a/js/model.js b/js/model.js
index 915e6f8f..a7a43640 100644
--- a/js/model.js
+++ b/js/model.js
@@ -269,7 +269,7 @@ class Model {
// const TensorBatch = this.fixUpSpecBatch(specs); // + 1 tensor
// specs.dispose(); // - 1 tensor
let paddedTensorBatch, maskedTensorBatch;
- if (BACKEND === 'webgl' && TensorBatch.shape[0] < this.batchSize) {
+ if (BACKEND === 'webgl' && TensorBatch.shape[0] < this.batchSize && !this.selection) {
// WebGL backend works best when all batches are the same size
paddedTensorBatch = this.padBatch(TensorBatch) // + 1 tensor
} else if (threshold) {
diff --git a/js/state.js b/js/state.js
index e6672e7e..19defb47 100644
--- a/js/state.js
+++ b/js/state.js
@@ -32,7 +32,7 @@ export class State {
this.list = 'everything',
this.customList = undefined,
this.local = true,
- this.incrementor = 1,
+ this.incrementor = 5,
this.UUID = 0,
this.track = true,
this.powerSaveBlocker = false,
diff --git a/js/ui.js b/js/ui.js
index 7af16f37..9eb9f1fd 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -1,6 +1,6 @@
import {trackVisit, trackEvent} from './tracking.js';
-let shownDaylightBanner = false, LOCATIONS, locationID = undefined, loadingTimeout;
+let LOCATIONS, locationID = undefined, loadingTimeout;
const startTime = performance.now();
let LABELS = [], DELETE_HISTORY = [];
// Save console.warn and console.error functions
@@ -53,7 +53,8 @@ window.addEventListener('rejectionhandled', function(event) {
config.track && trackEvent(config.UUID, 'Handled UI Promise Rejection', errorMessage, customURLEncode(stackTrace));
});
-const STATE = {
+let STATE = {
+ metadata: {},
mode: 'analyse',
analysisDone: false,
openFiles: [],
@@ -68,7 +69,8 @@ const STATE = {
},
sortOrder: 'timestamp',
birdList: { lastSelectedSpecies: undefined }, // Used to put the last selected species at the top of the all-species list
- selection: { start: undefined, end: undefined }
+ selection: { start: undefined, end: undefined },
+ currentAnalysis: {currentFile: null, openFiles: [], mode: null, species: null, offset: 0, active: null}
}
// Batch size map for slider
@@ -78,7 +80,6 @@ const BATCH_SIZE_LIST = [4, 8, 16, 32, 48, 64, 96];
const fs = window.module.fs;
const colormap = window.module.colormap;
const p = window.module.p;
-const SunCalc = window.module.SunCalc;
const uuidv4 = window.module.uuidv4;
const os = window.module.os;
@@ -151,11 +152,9 @@ window.electron.getVersion()
console.log('Error getting app version:', error)
});
-let modelReady = false, fileLoaded = false, currentFile;
+let modelReady = false, fileLoaded = false;
let PREDICTING = false, t0;
let region, AUDACITY_LABELS = {}, wavesurfer;
-// fileList is all open files, analyseList is the subset that have been analysed;
-let fileList = [], analyseList = [];
let fileStart, bufferStartTime, fileEnd;
@@ -182,6 +181,7 @@ const DOM = {
get audioNotification() { if (!this._audioNotification) { this._audioNotification = document.getElementById('audio-notification') } return this._audioNotification},
get batchSizeSlider() { if (!this._batchSizeSlider) { this._batchSizeSlider = document.getElementById('batch-size') } return this._batchSizeSlider},
get batchSizeValue() { if (!this._batchSizeValue) { this._batchSizeValue = document.getElementById('batch-size-value') } return this._batchSizeValue},
+ get chartsLink() { if (!this._chartsLink) { this._chartsLink = document.getElementById('charts') } return this._chartsLink},
get colourmap() { if (!this._colourmap) { this._colourmap = document.getElementById('colourmap') } return this._colourmap},
get contentWrapper() { if (!this._contentWrapper) { this._contentWrapper = document.getElementById('contentWrapper') } return this._contentWrapper},
get controlsWrapper() { if (!this._controlsWrapper) { this._controlsWrapper = document.getElementById('controlsWrapper') } return this._controlsWrapper},
@@ -190,6 +190,7 @@ const DOM = {
get debugMode() { if (!this._debugMode) { this._debugMode = document.getElementById('debug-mode') } return this._debugMode},
get defaultLat() { if (!this._defaultLat) { this._defaultLat = document.getElementById('latitude') } return this._defaultLat},
get defaultLon() { if (!this._defaultLon) { this._defaultLon = document.getElementById('longitude') } return this._defaultLon},
+ get exploreLink() { if (!this._exploreLink) { this._exploreLink = document.getElementById('explore') } return this._exploreLink},
get exploreWrapper() { if (!this._exploreWrapper) { this._exploreWrapper = document.getElementById('exploreWrapper') } return this._exploreWrapper},
get fileNumber() { if (!this._fileNumber) { this._fileNumber = document.getElementById('fileNumber') } return this._fileNumber},
get footer() { if (!this._footer) { this._footer = document.querySelector('footer') } return this._footer },
@@ -230,6 +231,7 @@ const DOM = {
get resultTable() {return document.getElementById('resultTableBody')},
get tooltip() { return document.getElementById('tooltip')},
get waveElement() { return document.getElementById('waveform')},
+ get summary() { return document.getElementById('summary')},
get specElement() { return document.getElementById('spectrogram')},
get specCanvasElement() { return document.querySelector('#spectrogram canvas')},
get waveCanvasElement() { return document.querySelector('#waveform canvas')},
@@ -308,15 +310,13 @@ DIAGNOSTICS['System Memory'] = (os.totalmem() / (1024 ** 2 * 1000)).toFixed(0) +
function resetResults({clearSummary = true, clearPagination = true, clearResults = true} = {}) {
if (clearSummary) summaryTable.textContent = '';
-
- clearPagination && pagination.forEach(item => item.classList.add('d-none'));
+ if (clearPagination) pagination.forEach(item => item.classList.add('d-none'));
resultsBuffer = DOM.resultTable.cloneNode(false)
if (clearResults) {
DOM.resultTable.textContent = '';
DOM.resultHeader.textContent = '';
}
predictions = {};
- shownDaylightBanner = false;
DOM.progressDiv.classList.add('d-none');
updateProgress(0)
}
@@ -345,7 +345,7 @@ function updateProgress(val) {
* @param {*} preserveResults: whether to clear results when opening file (i.e. don't clear results when clicking file in list of open files)
*
*/
-async function loadAudioFile({ filePath = '', preserveResults = false }) {
+function loadAudioFile({ filePath = '', preserveResults = false }) {
fileLoaded = false; locationID = undefined;
//if (!preserveResults) worker.postMessage({ action: 'change-mode', mode: 'analyse' })
worker.postMessage({
@@ -360,11 +360,16 @@ async function loadAudioFile({ filePath = '', preserveResults = false }) {
function updateSpec({ buffer, play = false, position = 0, resetSpec = false }) {
- showElement(['spectrogramWrapper'], false);
- wavesurfer.loadDecodedBuffer(buffer);
+ if (resetSpec || DOM.spectrogramWrapper.classList.contains('d-none')){
+ DOM.spectrogramWrapper.classList.remove('d-none');
+ wavesurfer.loadDecodedBuffer(buffer);
+ adjustSpecDims(true);
+ } else {
+ wavesurfer.loadDecodedBuffer(buffer);
+ }
wavesurfer.seekTo(position);
play ? wavesurfer.play() : wavesurfer.pause();
- resetSpec && adjustSpecDims(true);
+
}
function createTimeline() {
@@ -582,7 +587,7 @@ function powerSave(on) {
const openFileInList = async (e) => {
if (!PREDICTING && e.target.tagName === 'A') {
- await loadAudioFile({ filePath: e.target.id, preserveResults: true })
+ loadAudioFile({ filePath: e.target.id, preserveResults: true })
}
}
@@ -654,7 +659,7 @@ function showDatePicker() {
const timestamp = new Date(newStart).getTime();
// Send the data to the worker
- worker.postMessage({ action: 'update-file-start', file: currentFile, start: timestamp });
+ worker.postMessage({ action: 'update-file-start', file: STATE.currentFile, start: timestamp });
trackEvent(config.UUID, 'Settings Change', 'fileStart', newStart);
resetResults();
fileStart = timestamp;
@@ -694,36 +699,40 @@ function extractFileNameAndFolder(path) {
* @returns {string} - HTML string of the formatted Bootstrap table
*/
function formatAsBootstrapTable(jsonData) {
- let tableHtml = "Key Value ";
-
- // Iterate over the key-value pairs in the JSON object
- for (const [key, value] of Object.entries(JSON.parse(jsonData))) {
- tableHtml += '';
- tableHtml += `${key} `;
-
- // Check if the value is an object or array, if so, stringify it
- if (typeof value === 'object') {
- tableHtml += `${JSON.stringify(value, null, 2)} `;
- } else {
- tableHtml += `${value} `;
+ let parsedData = JSON.parse(jsonData);
+ let tableHtml = "
';
- return tableHtml;
+ tableHtml += ' ';
+ return tableHtml
}
-function showGUANO(){
- const icon = document.getElementById('guano');
- if (STATE.guano){
+function showMetadata(){
+ const icon = document.getElementById('metadata');
+ if (STATE.metadata[STATE.currentFile]){
icon.classList.remove('d-none');
icon.setAttribute('data-bs-content', 'New content for the popover');
// Reinitialize the popover to reflect the updated content
const popover = bootstrap.Popover.getInstance(icon);
popover.setContent({
- '.popover-header': 'GUANO Metadata',
- '.popover-body': formatAsBootstrapTable(STATE.guano)
+ '.popover-header': 'Metadata',
+ '.popover-body': formatAsBootstrapTable(STATE.metadata[STATE.currentFile])
});
} else {
icon.classList.add('d-none');
@@ -731,10 +740,10 @@ function showGUANO(){
}
function renderFilenamePanel() {
- if (!currentFile) return;
- const openfile = currentFile;
- const files = fileList;
- showGUANO();
+ if (!STATE.currentFile) return;
+ const openfile = STATE.currentFile;
+ const files = STATE.openFiles;
+ showMetadata();
let filenameElement = DOM.filename;
filenameElement.innerHTML = '';
//let label = openfile.replace(/^.*[\\\/]/, "");
@@ -806,8 +815,8 @@ async function generateLocationList(id) {
const defaultText = id === 'savedLocations' ? '(Default)' : 'All';
const el = document.getElementById(id);
LOCATIONS = undefined;
- worker.postMessage({ action: 'get-locations', file: currentFile });
- await waitForLocations();
+ worker.postMessage({ action: 'get-locations', file: STATE.currentFile });
+ await waitFor(() => LOCATIONS);
el.innerHTML = `
${defaultText} `; // clear options
LOCATIONS.forEach(loc => {
const option = document.createElement('option')
@@ -834,7 +843,7 @@ const showLocation = async (fromSelect) => {
const customPlaceEl = document.getElementById('customPlace');
const locationSelect = document.getElementById('savedLocations');
// Check if currentfile has a location id
- const id = fromSelect ? parseInt(locationSelect.value) : FILE_LOCATION_MAP[currentFile];
+ const id = fromSelect ? parseInt(locationSelect.value) : FILE_LOCATION_MAP[STATE.currentFile];
if (id) {
newLocation = LOCATIONS.find(obj => obj.id === id);
@@ -928,7 +937,7 @@ async function setCustomLocation() {
const customPlaceEl = document.getElementById('customPlace');
const locationAdd = document.getElementById('set-location');
const batchWrapper = document.getElementById('location-batch-wrapper');
- fileList.length > 1 ? batchWrapper.classList.remove('d-none') : batchWrapper.classList.add('d-none');
+ STATE.openFiles.length > 1 ? batchWrapper.classList.remove('d-none') : batchWrapper.classList.add('d-none');
// Use the current file location for lat, lon, place or use defaults
showLocation(false);
savedLocationSelect.addEventListener('change', function (e) {
@@ -959,8 +968,10 @@ async function setCustomLocation() {
const addLocation = () => {
locationID = savedLocationSelect.value;
const batch = document.getElementById('batchLocations').checked;
- const files = batch ? STATE.openFiles : [currentFile];
+ const files = batch ? STATE.openFiles : [STATE.currentFile];
worker.postMessage({ action: 'set-custom-file-location', lat: latEl.value, lon: lonEl.value, place: customPlaceEl.value, files: files })
+ generateLocationList('explore-locations');
+
locationModal.hide();
}
locationAdd.addEventListener('click', addLocation)
@@ -1004,14 +1015,20 @@ async function onOpenFiles(args) {
showElement(['spectrogramWrapper'], false);
resetResults({clearSummary: true, clearPagination: true, clearResults: true});
resetDiagnostics();
+ STATE.openFiles = sanitisedList;
+ // CHeck not more than 25k files
+ if (STATE.openFiles.length >= 25_000){
+ generateToast({message: `Chirpity limits the maximum number of open files to 25,000. Only the first 25,000 of the ${STATE.openFiles.length} attempted will be opened`})
+ STATE.openFiles.splice(25000)
+ }
// Store the file list and Load First audio file
- fileList = sanitisedList;
- fileList.length > 1 && fileList.length < 25_000 && worker.postMessage({action: 'check-all-files-saved', files: fileList});
- STATE.openFiles = fileList;
+
+ STATE.openFiles.length > 1 && worker.postMessage({action: 'check-all-files-saved', files: STATE.openFiles});
+
// Sort file by time created (the oldest first):
- if (fileList.length > 1) {
+ if (STATE.openFiles.length > 1) {
if (modelReady) enableMenuItem(['analyseAll', 'reanalyseAll'])
- fileList = fileList.map(fileName => ({
+ STATE.openFiles = STATE.openFiles.map(fileName => ({
name: fileName,
time: fs.statSync(fileName).mtime.getTime(),
}))
@@ -1022,7 +1039,7 @@ async function onOpenFiles(args) {
}
// Reset analysis status
STATE.analysisDone = false;
- await loadAudioFile({ filePath: fileList[0] });
+ loadAudioFile({ filePath: STATE.openFiles[0] });
disableMenuItem(['analyseSelection', 'analyse', 'analyseAll', 'reanalyse', 'reanalyseAll', 'save2db'])
// Clear unsaved records warning
window.electron.unsavedRecords(false);
@@ -1039,7 +1056,10 @@ async function onOpenFiles(args) {
* @returns {Promise
}
*/
async function showSaveDialog() {
- await window.electron.saveFile({ currentFile: currentFile, labels: AUDACITY_LABELS[currentFile] });
+ await window.electron.saveFile({
+ currentFile: STATE.currentFile,
+ labels: AUDACITY_LABELS[STATE.currentFile],
+ type: 'audacity' });
}
function resetDiagnostics() {
@@ -1054,7 +1074,6 @@ function resetDiagnostics() {
function analyseReset() {
clearActive();
DOM.fileNumber.textContent = '';
- if (STATE.mode === 'analyse') PREDICTING = true;
resetDiagnostics();
AUDACITY_LABELS = {};
DOM.progressDiv.classList.remove('d-none');
@@ -1073,7 +1092,7 @@ function refreshResultsView() {
if (!isEmptyObject(predictions)) {
showElement(['resultTableContainer', 'resultsHead'], false);
}
- } else if (!fileList.length) {
+ } else if (!STATE.openFiles.length) {
hideAll();
}
}
@@ -1089,7 +1108,7 @@ const getSelectionResults = (fromDB) => {
STATE['selection']['end'] = end.toFixed(3);
postAnalyseMessage({
- filesInScope: [currentFile],
+ filesInScope: [STATE.currentFile],
start: STATE['selection']['start'],
end: STATE['selection']['end'],
offset: 0,
@@ -1102,10 +1121,10 @@ function postAnalyseMessage(args) {
if (!PREDICTING) {
// Start a timer
t0_analysis = Date.now();
- disableMenuItem(['analyseSelection']);
+ disableMenuItem(['analyseSelection', 'explore', 'charts']);
const selection = !!args.end;
const filesInScope = args.filesInScope;
-
+ PREDICTING = true;
if (!selection) {
analyseReset();
refreshResultsView();
@@ -1149,8 +1168,8 @@ async function fetchLocationAddress(lat, lon) {
openStreetMapTimer = setTimeout(async () => {
try {
if (!LOCATIONS) {
- worker.postMessage({ action: 'get-locations', file: currentFile });
- await waitForLocations(); // Ensure this is awaited
+ worker.postMessage({ action: 'get-locations', file: STATE.currentFile });
+ await waitFor(() => LOCATIONS); // Ensure this is awaited
}
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}&zoom=14`);
@@ -1254,7 +1273,7 @@ async function exportData(format, species, limit, duration){
format: format,
duration: duration,
species: species,
- files: isExplore() ? [] : fileList,
+ files: isExplore() ? [] : STATE.openFiles,
explore: isExplore(),
limit: limit,
range: isExplore() ? STATE.explore.range : undefined
@@ -1262,31 +1281,53 @@ async function exportData(format, species, limit, duration){
}
}
-const chartsLink = document.getElementById('charts');
-chartsLink.addEventListener('click', async () => {
+
+const handleLocationFilterChange = (e) => {
+ const location = parseInt(e.target.value) || undefined;
+ worker.postMessage({ action: 'update-state', locationID: location });
+ // Update the seen species list
+ worker.postMessage({ action: 'get-detected-species-list' })
+ worker.postMessage({ action: 'update-state', globalOffset: 0, filteredOffset: {}});
+ if (STATE.mode === 'explore') filterResults()
+}
+
+function saveAnalyseState() {
+ if (['analyse', 'archive'].includes(STATE.mode)){
+ const active = activeRow?.rowIndex -1 || null
+ // Store a reference to the current file
+ STATE.currentAnalysis = {
+ currentFile: STATE.currentFile,
+ openFiles: STATE.openFiles,
+ mode: STATE.mode,
+ species: isSpeciesViewFiltered(true),
+ offset: STATE.offset,
+ active: active,
+ analysisDone: STATE.analysisDone,
+ sortOrder: STATE.sortOrder
+ }
+ }
+}
+
+async function showCharts() {
+ saveAnalyseState();
+ enableMenuItem(['active-analysis', 'explore']);
+ disableMenuItem(['analyse', 'analyseSelection', 'analyseAll', 'reanalyse', 'reanalyseAll', 'charts'])
// Tell the worker we are in Chart mode
worker.postMessage({ action: 'change-mode', mode: 'chart' });
// Disable analyse file links
- disableMenuItem(['analyse', 'analyseSelection', 'analyseAll', 'reanalyse', 'reanalyseAll'])
worker.postMessage({ action: 'get-detected-species-list', range: STATE.chart.range });
const locationFilter = await generateLocationList('chart-locations');
locationFilter.addEventListener('change', handleLocationFilterChange);
hideAll();
showElement(['recordsContainer']);
worker.postMessage({ action: 'chart', species: undefined, range: STATE.chart.range });
-});
-
-const handleLocationFilterChange = (e) => {
- const location = e.target.value || undefined;
- worker.postMessage({ action: 'update-state', locationID: location });
- // Update the seen species list
- worker.postMessage({ action: 'get-detected-species-list' })
- worker.postMessage({ action: 'update-state', globalOffset: 0, filteredOffset: {}});
- if (STATE.mode === 'explore') filterResults()
-}
+};
-const exploreLink = document.getElementById('explore');
-exploreLink.addEventListener('click', async () => {
+async function showExplore() {
+ saveAnalyseState();
+ enableMenuItem(['saveCSV', 'save-eBird', 'save-Raven', 'charts', 'active-analysis']);
+ disableMenuItem(['explore', 'save2db']);
+ STATE.openFiles = [];
// Tell the worker we are in Explore mode
worker.postMessage({ action: 'change-mode', mode: 'explore' });
worker.postMessage({ action: 'get-detected-species-list', range: STATE.explore.range });
@@ -1294,22 +1335,37 @@ exploreLink.addEventListener('click', async () => {
locationFilter.addEventListener('change', handleLocationFilterChange);
hideAll();
showElement(['exploreWrapper'], false);
- enableMenuItem(['saveCSV', 'save-eBird', 'save-Raven']);
- worker.postMessage({ action: 'update-state', globalOffset: 0, filteredOffset: {}});
+ worker.postMessage({ action: 'update-state', filesToAnalyse: []});
// Analysis is done
STATE.analysisDone = true;
- filterResults({species: undefined, range: STATE.explore.range});
- resetResults({clearSummary: true, clearPagination: true, clearResults: true});
-});
+ filterResults({species: isSpeciesViewFiltered(true), range: STATE.explore.range});
+ resetResults();
+
+};
-const restoreLink = document.getElementById('restore');
-restoreLink.addEventListener('click', async () => {
- // Todo: Placeholder for results restore
- worker.postMessage({ action: 'change-mode', mode: 'analyse' });
+async function showAnalyse() {
+ disableMenuItem(['active-analysis']);
+ //Restore STATE
+ STATE = {...STATE, ...STATE.currentAnalysis}
+ worker.postMessage({ action: 'change-mode', mode: STATE.mode });
hideAll();
- showElement(['spectrogramWrapper', 'resultTableContainer']);
- worker.postMessage({ action: 'filter', updateSummary: true });
-});
+ if (STATE.currentFile) {
+ showElement(['spectrogramWrapper'], false);
+ worker.postMessage({ action: 'update-state', filesToAnalyse: STATE.openFiles, sortOrder: STATE.sortOrder});
+ if (STATE.analysisDone) {
+ filterResults({
+ species: STATE.species,
+ offset: STATE.offset,
+ active: STATE.active,
+ updateSummary: true });
+ } else {
+ clearActive();
+ loadAudioFile({filePath: STATE.currentFile});
+ }
+ }
+ resetResults();
+};
+
// const datasetLink = document.getElementById('dataset');
// datasetLink.addEventListener('click', async () => {
@@ -1364,8 +1420,8 @@ selectionTable.addEventListener('click', resultClick);
async function resultClick(e) {
let row = e.target.closest('tr');
- if (!row || row.classList.length === 0 || row.classList.contains('text-bg-dark')) {
- // 1. clicked and dragged, 2 no detections in file row, 3. daylight banner
+ if (!row || row.classList.length === 0) {
+ // 1. clicked and dragged, 2 no detections in file row
return
}
@@ -1376,7 +1432,7 @@ async function resultClick(e) {
return;
}
- // Search for results rows
+ // Search for results rows - Why???
while (!(row.classList.contains('nighttime') ||
row.classList.contains('daytime'))) {
row = row.previousElementSibling
@@ -1387,7 +1443,7 @@ async function resultClick(e) {
activeRow = row;
loadResultRegion({ file, start, end, label });
if (e.target.classList.contains('circle')) {
- await waitForFileLoad();
+ await waitFor(()=> fileLoaded);
getSelectionResults(true);
}
}
@@ -1428,7 +1484,7 @@ function adjustSpecDims(redraw, fftSamples, newHeight) {
config.specMaxHeight = specHeight;
scheduler.postTask(() => updatePrefs('config.json', config), {priority: 'background'});
}
- if (currentFile) {
+ if (STATE.currentFile) {
// give the wrapper space for the transport controls and element padding/margins
if (!wavesurfer) {
initWavesurfer({
@@ -1446,7 +1502,7 @@ function adjustSpecDims(redraw, fftSamples, newHeight) {
if (wavesurfer && redraw) {
specOffset = spectrogramWrapper.offsetHeight;
}
- } else {
+ } else {
specOffset = 0
}
DOM.resultTableElement.style.height = (contentHeight - specOffset - formOffset) + 'px';
@@ -1641,16 +1697,25 @@ function updatePrefs(file, data) {
}
}
-function fillDefaults(config, defaultConfig) {
+function syncConfig(config, defaultConfig) {
+ // First, remove keys from config that are not in defaultConfig
+ Object.keys(config).forEach(key => {
+ if (!(key in defaultConfig)) {
+ delete config[key];
+ }
+ });
+
+ // Then, fill in missing keys from defaultConfig
Object.keys(defaultConfig).forEach(key => {
if (!(key in config)) {
config[key] = defaultConfig[key];
} else if (typeof config[key] === 'object' && typeof defaultConfig[key] === 'object') {
- // Recursively fill in defaults for nested objects
- fillDefaults(config[key], defaultConfig[key]);
+ // Recursively sync nested objects
+ syncConfig(config[key], defaultConfig[key]);
}
});
}
+
///////////////////////// Window Handlers ////////////////////////////
// Set config defaults
const defaultConfig = {
@@ -1694,7 +1759,7 @@ let appPath, tempPath, isMac;
window.onload = async () => {
window.electron.requestWorkerChannel();
isMac = await window.electron.isMac();
- replaceCtrlWithCommand()
+ if (isMac) replaceCtrlWithCommand()
DOM.contentWrapper.classList.add('loaded');
// Load preferences and override defaults
@@ -1721,20 +1786,7 @@ window.onload = async () => {
return false;
};
//fill in defaults - after updates add new items
- fillDefaults(config, defaultConfig);
- // Reset defaults for tensorflow batchsize. If removing, update change handler for batch-size
- if (config.tensorflow.batchSizeWasReset !== true && config.tensorflow.batchSize === 32) {
- const RAM = parseInt(DIAGNOSTICS['System Memory'].replace(' GB', ''));
- if (!RAM || RAM < 16){
- config.tensorflow.batchSize = 8;
- generateToast({message: `The new default CPU backend batch size of 8 has been applied.
- This should result in faster prediction due to lower memory requirements.
- Batch size can still be changed in settings`, autohide: false})
- }
- config.tensorflow.batchSizeWasReset = true;
- updatePrefs('config.json', config)
- }
-
+ syncConfig(config, defaultConfig);
// set version
config.VERSION = VERSION;
// switch off debug mode we don't want this to be remembered
@@ -1920,9 +1972,10 @@ const setUpWorkerMessaging = () => {
break;
}
case "diskDB-has-records": {
- chartsLink.classList.remove("disabled");
- exploreLink.classList.remove("disabled");
+ DOM.chartsLink.classList.remove("disabled");
+ DOM.exploreLink.classList.remove("disabled");
config.archive.location && document.getElementById('compress-and-organise').classList.remove('disabled');
+ STATE.diskHasRecords = true;
break;
}
case "file-location-id": {onFileLocationID(args);
@@ -1944,11 +1997,11 @@ const setUpWorkerMessaging = () => {
MISSING_FILE = args.file;
args.message += `
-
+
Locate File
-
- Delete File
+
+ Remove from Archive
`
@@ -1978,13 +2031,23 @@ const setUpWorkerMessaging = () => {
const mode = args.mode;
STATE.mode = mode;
renderFilenamePanel();
+ switch (mode) {
+ case 'analyse':{
+ STATE.diskHasRecords && !PREDICTING && enableMenuItem(['explore', 'charts']);
+ break
+ }
+ case 'archive':{
+ enableMenuItem(['save2db', 'explore', 'charts']);
+ break
+ }
+ }
config.debug && console.log("Mode changed to: " + mode);
- if (mode === 'archive' || mode === 'explore') {
+ if (['archive', 'explore'].includes(mode)) {
enableMenuItem(['purge-file']);
// change header to indicate activation
DOM.resultHeader.classList.remove('text-bg-secondary');
DOM.resultHeader.classList.add('text-bg-dark');
- adjustSpecDims(true)
+ //adjustSpecDims(true)
} else {
disableMenuItem(['purge-file']);
// change header to indicate deactivation
@@ -2005,8 +2068,6 @@ const setUpWorkerMessaging = () => {
// called when an analysis ends, or when the filesbeingprocessed list is empty
case "processing-complete": {
STATE.analysisDone = true;
- //PREDICTING = false;
- //DOM.progressDiv.classList.add('d-none');
break;
}
case 'ready-for-tour':{
@@ -2193,16 +2254,15 @@ document.addEventListener('change', function (e) {
})
// Save audio clip
-function onSaveAudio({file, filename}){
- const anchor = document.createElement('a');
- document.body.appendChild(anchor);
- anchor.style = 'display: none';
- const url = window.URL.createObjectURL(file);
- anchor.href = url;
- anchor.download = filename;
- anchor.click();
- window.URL.revokeObjectURL(url);
- anchor.remove()
+async function onSaveAudio({file, filename, extension}){
+
+ await window.electron.saveFile({
+ file: file,
+ filename: filename,
+ extension: extension
+ })
+
+
}
@@ -2506,14 +2566,10 @@ function onChartData(args) {
window.addEventListener('resize', function () {
waitForFinalEvent(function () {
- WindowResize();
- }, 250, 'id1');
+ adjustSpecDims(true);
+ }, 100, 'id1');
});
- function WindowResize() {
- adjustSpecDims(true);
- }
-
const contextMenu = document.getElementById('context-menu')
function handleKeyDownDeBounce(e) {
@@ -2607,6 +2663,8 @@ function onChartData(args) {
const specDimensions = waveElement.getBoundingClientRect();
const frequencyRange = Number(config.audio.maxFrequency) - Number(config.audio.minFrequency);
const yPosition = Math.round((specDimensions.bottom - event.clientY) * (frequencyRange / specDimensions.height)) + Number(config.audio.minFrequency);
+
+ // Update the tooltip content
tooltip.textContent = `Frequency: ${yPosition}Hz`;
if (region) {
const lineBreak = document.createElement('br');
@@ -2615,15 +2673,32 @@ function onChartData(args) {
tooltip.appendChild(lineBreak); // Add the line break
tooltip.appendChild(textNode); // Add the text node
}
+
+ // Get the tooltip's dimensions
+ tooltip.style.display = 'block'; // Ensure tooltip is visible to measure dimensions
+ const tooltipWidth = tooltip.offsetWidth;
+ const windowWidth = window.innerWidth;
+
+ // Calculate the new tooltip position
+ let tooltipLeft;
+
+ // If the tooltip would overflow past the right side of the window, position it to the left
+ if (event.clientX + tooltipWidth + 15 > windowWidth) {
+ tooltipLeft = event.clientX - tooltipWidth - 5; // Position to the left of the mouse cursor
+ } else {
+ tooltipLeft = event.clientX + 15; // Position to the right of the mouse cursor
+ }
+
+ // Apply styles to the tooltip
Object.assign(tooltip.style, {
top: `${event.clientY}px`,
- left: `${event.clientX + 15}px`,
+ left: `${tooltipLeft}px`,
display: 'block',
- visibility: 'visible'
+ visibility: 'visible',
+ opacity: 1
});
}
-
const LIST_MAP = {
location: 'Searching for birds in your region',
nocturnal: 'Searching for nocturnal birds',
@@ -2646,7 +2721,7 @@ function onChartData(args) {
DOM.listToUse.value = config.list;
updateListIcon();
updatePrefs('config.json', config)
- resetResults({clearSummary: true, clearPagination: true, clearResults: true});
+ resetResults();
setListUIState(config.list);
// Since the custom list function calls for its own update *after* reading the labels, we'll skip updates for custom lists here
config.list === 'custom' || worker.postMessage({ action: 'update-list', list: config.list, refreshResults: STATE.analysisDone })
@@ -2667,6 +2742,7 @@ function onChartData(args) {
const loadModel = () => {
+ PREDICTING = false;
t0_warmup = Date.now();
worker.postMessage({
action: 'load-model',
@@ -2676,8 +2752,7 @@ function onChartData(args) {
warmup: config.warmup,
threads: config[config[config.model].backend].threads,
backend: config[config.model].backend
- });
-
+ })
}
const handleBackendChange = (backend) => {
@@ -2702,7 +2777,6 @@ function onChartData(args) {
contextAwareIconDisplay();
}
}
- PREDICTING = false;
}
// Update threads and batch Size in UI
DOM.threadSlider.value = config[config[config.model].backend].threads;
@@ -2715,12 +2789,6 @@ function onChartData(args) {
loadModel();
}
- const backend = document.getElementsByName('backend');
- for (let i = 0; i < backend.length; i++) {
- backend[i].addEventListener('click', handleBackendChange)
- }
-
-
const setTimelinePreferences = () => {
const timestampFields = document.querySelectorAll('.timestamp');
const timeOfDayFields = document.querySelectorAll('.timeOfDay');
@@ -2770,12 +2838,12 @@ function centreSpec(){
const GLOBAL_ACTIONS = { // eslint-disable-line
a: function (e) {
- if (( e.ctrlKey || e.metaKey) && currentFile) {
+ if (( e.ctrlKey || e.metaKey) && STATE.currentFile) {
const element = e.shiftKey ? 'analyseAll' : 'analyse';
document.getElementById(element).click();
}
},
- A: function (e) { ( e.ctrlKey || e.metaKey) && currentFile && document.getElementById('analyseAll').click()},
+ A: function (e) { ( e.ctrlKey || e.metaKey) && STATE.currentFile && document.getElementById('analyseAll').click()},
c: function (e) {
// Center window on playhead
if (( e.ctrlKey || e.metaKey) && currentBuffer) {
@@ -2802,7 +2870,7 @@ function centreSpec(){
},
s: function (e) {
if ( e.ctrlKey || e.metaKey) {
- worker.postMessage({ action: 'save2db', file: currentFile});
+ worker.postMessage({ action: 'save2db', file: STATE.currentFile});
}
},
t: function (e) {
@@ -2830,6 +2898,7 @@ function centreSpec(){
threads: config[config[config.model].backend].threads,
list: config.list
});
+ STATE.diskHasRecords && enableMenuItem(['explore', 'charts']);
generateToast({ message:'Operation cancelled'});
DOM.progressDiv.classList.add('d-none');
}
@@ -2901,9 +2970,15 @@ function centreSpec(){
'=': function (e) {e.metaKey || e.ctrlKey ? reduceFFT() : zoomSpec('zoomIn')},
'+': function (e) {e.metaKey || e.ctrlKey ? reduceFFT() : zoomSpec('zoomIn')},
'-': function (e) {e.metaKey || e.ctrlKey ? increaseFFT() : zoomSpec('zoomOut')},
+ 'F5': function (e) { reduceFFT() },
+ 'F4': function (e) { increaseFFT() },
' ': function () { wavesurfer && wavesurfer.playPause() },
Tab: function (e) {
- if (activeRow) {
+ if ((e.metaKey || e.ctrlKey) && ! PREDICTING) { // If you did this when predicting, your results would go straight to the archive
+ const modeToSet = STATE.mode === 'explore' ? 'active-analysis' : 'explore';
+ document.getElementById(modeToSet).click()
+ }
+ else if (activeRow) {
activeRow.classList.remove('table-active')
if (e.shiftKey) {
activeRow = activeRow.previousSibling || activeRow;
@@ -2929,7 +3004,7 @@ function centreSpec(){
}
const postBufferUpdate = ({
- file = currentFile,
+ file = STATE.currentFile,
begin = 0,
position = 0,
play = false,
@@ -2962,7 +3037,7 @@ function centreSpec(){
// Go to position
const goto = new bootstrap.Modal(document.getElementById('gotoModal'));
const showGoToPosition = () => {
- if (currentFile) {
+ if (STATE.currentFile) {
goto.show();
}
}
@@ -2978,7 +3053,7 @@ function centreSpec(){
const gotoTime = (e) => {
- if (currentFile) {
+ if (STATE.currentFile) {
e.preventDefault();
let hours = 0, minutes = 0, seconds = 0;
const time = document.getElementById('timeInput').value;
@@ -3018,7 +3093,7 @@ function centreSpec(){
sampleRate = args.sampleRate;
if (fileLoaded) {
enableMenuItem(['analyse'])
- if (fileList.length > 1) enableMenuItem(['analyseAll', 'reanalyseAll'])
+ if (STATE.openFiles.length > 1) enableMenuItem(['analyseAll', 'reanalyseAll'])
}
if (region) enableMenuItem(['analyseSelection'])
t1_warmup = Date.now();
@@ -3053,13 +3128,13 @@ function centreSpec(){
play = false,
queued = false,
goToRegion = true,
- guano = undefined
+ metadata = undefined
}) {
fileLoaded = true, locationID = location;
clearTimeout(loadingTimeout)
// Clear the loading animation
DOM.loading.classList.add('d-none');
- const resetSpec = !currentFile;
+ const resetSpec = !STATE.currentFile;
currentFileDuration = sourceDuration;
//if (preserveResults) completeDiv.hide();
console.log(`UI received worker-loaded-audio: ${file}, buffered: ${contents === undefined}`);
@@ -3077,13 +3152,16 @@ function centreSpec(){
}
} else {
NEXT_BUFFER = undefined;
- if (currentFile !== file) {
- currentFile = file;
- STATE.guano = guano;
- renderFilenamePanel();
+ if (STATE.currentFile !== file) {
+ STATE.currentFile = file;
+
fileStart = start;
fileEnd = new Date(fileStart + (currentFileDuration * 1000));
}
+
+ STATE.metadata[STATE.currentFile] = metadata;
+ renderFilenamePanel();
+
if (config.timeOfDay) {
bufferStartTime = new Date(fileStart + (bufferBegin * 1000))
} else {
@@ -3097,7 +3175,7 @@ function centreSpec(){
wavesurfer.bufferRequested = false;
if (modelReady) {
enableMenuItem(['analyse']);
- if (fileList.length > 1) enableMenuItem(['analyseAll'])
+ if (STATE.openFiles.length > 1) enableMenuItem(['analyseAll'])
}
if (fileRegion) {
createRegion(fileRegion.start, fileRegion.end, fileRegion.label, goToRegion);
@@ -3105,7 +3183,7 @@ function centreSpec(){
region.play()
}
} else {
- clearActive();
+ //clearActive();
}
fileLoaded = true;
//if (activeRow) activeRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@@ -3118,8 +3196,8 @@ function centreSpec(){
DOM.fileNumber.innerHTML = args.text;
} else {
DOM.progressDiv.classList.remove('d-none');
- const count = fileList.indexOf(args.file) + 1;
- DOM.fileNumber.textContent = `File ${count} of ${fileList.length}`;
+ const count = STATE.openFiles.indexOf(args.file) + 1;
+ DOM.fileNumber.textContent = `File ${count} of ${STATE.openFiles.length}`;
}
if (args.progress) {
let progress = Math.round(args.progress * 1000) / 10;
@@ -3132,48 +3210,50 @@ function centreSpec(){
function updatePagination(total, offset) {
//Pagination
total > config.limit ? addPagination(total, offset) : pagination.forEach(item => item.classList.add('d-none'));
+ STATE.offset = offset;
}
const updateSummary = ({ summary = [], filterSpecies = '' }) => {
- let total, summaryHTML = `
-
- Max
- Species
- Detections
- Calls
-
- `;
-
- for (let i = 0; i < summary.length; i++) {
- const item = summary[i];
- const selected = item.cname === filterSpecies ? ' text-warning' : '';
- summaryHTML += `
- ${iconizeScore(item.max)}
-
- ${item.cname} ${item.sname}
-
- ${item.count}
- ${item.calls}
- `;
+ if (summary.length){
+ let summaryHTML = `
+
+ Max
+ Species
+ Detections
+ Calls
+
+ `;
+ for (let i = 0; i < summary.length; i++) {
+ const item = summary[i];
+ const selected = item.cname === filterSpecies ? ' text-warning' : '';
+ summaryHTML += `
+ ${iconizeScore(item.max)}
+
+ ${item.cname} ${item.sname}
+
+ ${item.count}
+ ${item.calls}
+ `;
+
+ }
+ summaryHTML += '
';
+ // Get rid of flicker...
+ const old_summary = summaryTable;
+ const buffer = old_summary.cloneNode();
+ buffer.innerHTML = summaryHTML;
+ old_summary.replaceWith(buffer);
}
- summaryHTML += '
';
- // Get rid of flicker...
- const old_summary = summaryTable;
- const buffer = old_summary.cloneNode();
- buffer.innerHTML = summaryHTML;
- old_summary.replaceWith(buffer);
- const currentFilter = document.querySelector('#speciesFilter tr.text-warning');
}
/*
onResultsComplete is called when the last result is sent from the database
*/
function onResultsComplete({active = undefined, select = undefined} = {}){
- let table = document.getElementById('resultTableBody');
- table.replaceWith(resultsBuffer);
- table = document.getElementById('resultTableBody');
-
+ PREDICTING = false;
+ DOM.resultTable.replaceWith(resultsBuffer);
+ const table = DOM.resultTable;
+ showElement(['resultTableContainer', 'resultsHead'], false);
// Set active Row
if (active) {
// Refresh node and scroll to active row:
@@ -3183,9 +3263,7 @@ function centreSpec(){
if (rows.length) {
activeRow = rows[rows.length - 1];
}
- } else {
- activeRow.classList.add('table-active');
- }
+ }
} else if (select) {
const row = getRowFromStart(table, select)
activeRow = table.rows[row];
@@ -3204,7 +3282,7 @@ function centreSpec(){
}
// hide progress div
DOM.progressDiv.classList.add('d-none');
-
+ renderFilenamePanel();
}
function getRowFromStart(table, start){
@@ -3223,9 +3301,22 @@ function centreSpec(){
}
}
+// formatDuration: Used for DIAGNOSTICS Duration
+function formatDuration(seconds){
+ let duration = '';
+ const hours = Math.floor(seconds / 3600); // 1 hour = 3600 seconds
+ if (hours) duration += `${hours} hours `;
+ const minutes = Math.floor((seconds % 3600) / 60); // 1 minute = 60 seconds
+ if (hours || minutes) duration += `${minutes} minutes `;
+ const remainingSeconds = Math.floor(seconds % 60); // Remaining seconds
+ duration += `${remainingSeconds} seconds`;
+ return duration;
+}
+
function onAnalysisComplete({quiet}){
PREDICTING = false;
STATE.analysisDone = true;
+ STATE.diskHasRecords && enableMenuItem(['explore', 'charts']);
DOM.progressDiv.classList.add('d-none');
if (quiet) return
// DIAGNOSTICS:
@@ -3239,7 +3330,7 @@ function centreSpec(){
trackEvent(config.UUID, `${config.model}-${config[config.model].backend}`, 'Analysis Rate', config[config.model].backend, parseInt(rate));
if (! STATE.selection){
- DIAGNOSTICS['Analysis Duration'] = analysisTime + ' seconds';
+ DIAGNOSTICS['Analysis Duration'] = formatDuration(analysisTime);
DIAGNOSTICS['Analysis Rate'] = rate.toFixed(0) + 'x faster than real time performance.';
generateToast({ message:'Analysis complete.'});
activateResultFilters();
@@ -3259,12 +3350,13 @@ function centreSpec(){
if (! PREDICTING || STATE.mode !== 'analyse') activateResultFilters();
// Why do we do audacity labels here?
AUDACITY_LABELS = audacityLabels;
- if (! isEmptyObject(AUDACITY_LABELS)) {
- enableMenuItem(['saveLabels', 'saveCSV', 'save-eBird', 'save-Raven', 'save2db']);
+ if (isEmptyObject(AUDACITY_LABELS)) {
+ disableMenuItem(['saveLabels', 'saveCSV', 'save-eBird', 'save-Raven', 'save2db']);
} else {
- disableMenuItem(['saveLabels', 'saveCSV', 'save-eBird', 'save-Raven']);
+ enableMenuItem(['saveLabels', 'saveCSV', 'save-eBird', 'save-Raven']);
+ STATE.mode !== 'explore' && enableMenuItem(['save2db'])
}
- if (currentFile) enableMenuItem(['analyse'])
+ if (STATE.currentFile) enableMenuItem(['analyse'])
}
@@ -3321,8 +3413,6 @@ function centreSpec(){
})
}
- const summary = document.getElementById('summary');
- // summary.addEventListener('click', speciesFilter);
function speciesFilter(e) {
if (PREDICTING || ['TBODY', 'TH', 'DIV'].includes(e.target.tagName)) return; // on Drag or clicked header
@@ -3333,7 +3423,7 @@ function centreSpec(){
e.target.closest('tr').classList.remove('text-warning');
} else {
//Clear any highlighted rows
- const tableRows = summary.querySelectorAll('tr');
+ const tableRows = DOM.summary.querySelectorAll('tr');
tableRows.forEach(row => row.classList.remove('text-warning'))
// Add a highlight to the current row
e.target.closest('tr').classList.add('text-warning');
@@ -3345,16 +3435,10 @@ function centreSpec(){
const list = document.getElementById('bird-list-seen');
list.value = species || '';
}
- filterResults()
+ filterResults({updateSummary: false})
resetResults({clearSummary: false, clearPagination: false, clearResults: false});
}
-
- // const checkDayNight = (timestamp) => {
- // let astro = SunCalc.getTimes(timestamp, config.latitude, config.longitude);
- // return (astro.dawn.setMilliseconds(0) < timestamp && astro.dusk.setMilliseconds(0) > timestamp) ? 'daytime' : 'nighttime';
- // }
-
// TODO: show every detection in the spec window as a region on the spectrogram
async function renderResult({
@@ -3372,13 +3456,14 @@ function centreSpec(){
return
}
if (index <= 1) {
+ adjustSpecDims(true)
if (selection) {
const selectionTable = document.getElementById('selectionResultTableBody');
selectionTable.textContent = '';
}
else {
- adjustSpecDims(true);
- if (isFromDB) PREDICTING = false;
+ //adjustSpecDims(true);
+ //if (isFromDB) PREDICTING = false;
DOM.resultHeader.innerHTML =`
@@ -3413,21 +3498,10 @@ function centreSpec(){
callCount,
isDaylight
} = result;
- const dayNight = isDaylight ? 'daytime' : 'nighttime'; // checkDayNight(timestamp);
+ const dayNight = isDaylight ? 'daytime' : 'nighttime';
// Todo: move this logic so pre dark sections of file are not even analysed
if (config.detect.nocmig && !selection && dayNight === 'daytime') return
- // Show twilight indicator when nocmig mode off (except when analysing a selection)
- if (shownDaylightBanner === false && dayNight === 'daytime' && STATE.sortOrder === 'timestamp') {
- // Only do this if change starts midway through a file
- if ((index - 1) % config.limit !== 0) {
- // Show the twilight start bar
- tr += `
- Start of civil twilight wb_twilight
- `;
- }
- shownDaylightBanner = true;
- }
const commentHTML = comment ?
`comment ` : '';
const isUncertain = score < 65 ? '?' : '';
@@ -3518,7 +3592,7 @@ function centreSpec(){
const [start, end] = position;
worker.postMessage({
action: 'delete',
- file: currentFile,
+ file: STATE.currentFile,
start: start,
end: end,
active: getActiveRowID(),
@@ -3602,32 +3676,32 @@ function centreSpec(){
const dateString = dateArray.slice(0, 5).join(' ');
filename = dateString + '.' + config.audio.format;
}
-
+
let metadata = {
- lat: config.latitude,
- lon: config.longitude,
+ lat: parseFloat(config.latitude),
+ lon: parseFloat(config.longitude),
Artist: 'Chirpity',
- date: new Date().getFullYear(),
version: VERSION
};
if (result) {
+ const date = new Date(result.timestamp);
metadata = {
...metadata,
UUID: config.UUID,
start: start,
end: end,
- filename: result.filename,
+ //filename: result.file,
cname: result.cname,
sname: result.sname,
- score: result.score,
- date: result.date
+ score: parseInt(result.score),
+ Year: date.getFullYear()
};
}
if (mode === 'save') {
worker.postMessage({
action: 'save',
- start: start, file: currentFile, end: end, filename: filename, metadata: metadata
+ start: start, file: STATE.currentFile, end: end, filename: filename, metadata: metadata
})
} else {
if (!config.seenThanks) {
@@ -3637,7 +3711,7 @@ function centreSpec(){
}
worker.postMessage({
action: 'post',
- start: start, file: currentFile, end: end, defaultName: filename, metadata: metadata, mode: mode
+ start: start, file: STATE.currentFile, end: end, defaultName: filename, metadata: metadata, mode: mode
})
}
}
@@ -3677,9 +3751,9 @@ function centreSpec(){
const response = await fetch(file);
document.getElementById('helpModalBody').innerHTML = await response.text();
const help = new bootstrap.Modal(document.getElementById('helpModal'));
+ document.removeEventListener('show.bs.modal', replaceCtrlWithCommand);
+ document.addEventListener('show.bs.modal', replaceCtrlWithCommand);
help.show();
- document.removeEventListener('shown.bs.modal', replaceCtrlWithCommand);
- document.addEventListener('shown.bs.modal', replaceCtrlWithCommand);
}
function replaceTextInTitleAttributes() {
// Select all elements with title attribute in the body of the web page
@@ -3906,19 +3980,7 @@ function centreSpec(){
let diagnosticTable = "";
for (let [key, value] of Object.entries(DIAGNOSTICS)) {
if (key === 'Audio Duration') { // Format duration as days, hours,minutes, etc.
- if (value < 3600) {
- value = new Date(value * 1000).toISOString().substring(14, 19);
- value = value.replace(':', ' minutes ').concat(' seconds');
- } else if (value < 86400) {
- value = new Date(value * 1000).toISOString().substring(11, 19)
- value = value.replace(':', ' hours ').replace(':', ' minutes ').concat(' seconds')
- } else {
- value = new Date(value * 1000).toISOString().substring(8, 19);
- const day = parseInt(value.slice(0, 2)) - 1;
- const daysString = day === 1 ? '1 day ' : day.toString() + ' days ';
- const dateString = daysString + value.slice(3);
- value = dateString.replace(':', ' hours ').replace(':', ' minutes ').concat(' seconds');
- }
+ value = formatDuration(value)
}
diagnosticTable += `${key} ${value} `;
}
@@ -3952,7 +4014,7 @@ function centreSpec(){
}
});
// Add pointer icon to species summaries
- const summarySpecies = document.getElementById('summary').querySelectorAll('.cname');
+ const summarySpecies = DOM.summary.querySelectorAll('.cname');
summarySpecies.forEach(row => row.classList.add('pointer'))
// change results header to indicate activation
DOM.resultHeader.classList.remove('text-bg-secondary');
@@ -4284,7 +4346,7 @@ function centreSpec(){
action: 'update-state',
detect: { confidence: config.detect.confidence }
});
- if (STATE.mode == 'explore') {
+ if (STATE.mode === 'explore') {
// Update the seen species list
worker.postMessage({ action: 'get-detected-species-list' })
}
@@ -4418,6 +4480,7 @@ DOM.gain.addEventListener('input', () => {
const target = e.target.closest('[id]')?.id;
switch (target)
{
+ // File menu
case 'open-file': { showOpenDialog('openFile'); break }
case 'open-folder': { showOpenDialog('openDirectory'); break }
case 'saveLabels': { showSaveDialog(); break }
@@ -4427,19 +4490,30 @@ DOM.gain.addEventListener('input', () => {
case 'export-audio': { exportAudio(); break }
case 'exit': { exitApplication(); break }
+
+ case 'dataset': { worker.postMessage({ action: 'create-dataset', species: isSpeciesViewFiltered(true) }); break }
+
+ // Records menu
case 'save2db': {
- worker.postMessage({ action: 'save2db', file: currentFile });
+ worker.postMessage({ action: 'save2db', file: STATE.currentFile });
if (config.archive.auto) document.getElementById('compress-and-organise').click();
break }
- case 'dataset': { worker.postMessage({ action: 'create-dataset', species: isSpeciesViewFiltered(true) }); break }
+ case 'charts': { showCharts(); break }
+ case 'explore': { showExplore(); break }
+ case 'active-analysis': { showAnalyse(); break }
+ case 'compress-and-organise': {compressAndOrganise(); break}
+ case 'purge-file': { deleteFile(STATE.currentFile); break }
- case 'analyse': {postAnalyseMessage({ filesInScope: [currentFile] }); break }
+ //Analyse menu
+ case 'analyse': {postAnalyseMessage({ filesInScope: [STATE.currentFile] }); break }
case 'analyseSelection': {getSelectionResults(); break }
- case 'analyseAll': {postAnalyseMessage({ filesInScope: fileList }); break }
- case 'reanalyse': {postAnalyseMessage({ filesInScope: [currentFile], reanalyse: true }); break }
- case 'reanalyseAll': {postAnalyseMessage({ filesInScope: fileList, reanalyse: true }); break }
+ case 'analyseAll': {postAnalyseMessage({ filesInScope: STATE.openFiles }); break }
+ case 'reanalyse': {postAnalyseMessage({ filesInScope: [STATE.currentFile], reanalyse: true }); break }
+ case 'reanalyseAll': {postAnalyseMessage({ filesInScope: STATE.openFiles, reanalyse: true }); break }
case 'purge-from-toast': { deleteFile(MISSING_FILE); break }
+
+ // ----
case 'locate-missing-file': {
(async () => await locateFile(MISSING_FILE))();
break }
@@ -4453,19 +4527,32 @@ DOM.gain.addEventListener('input', () => {
updatePrefs('config.json', config)
resetResults({clearSummary: true, clearPagination: true, clearResults: true});
setListUIState(config.list);
- if (currentFile && STATE.analysisDone) worker.postMessage({ action: 'update-list', list: config.list, refreshResults: true })
+ if (STATE.currentFile && STATE.analysisDone) worker.postMessage({ action: 'update-list', list: config.list, refreshResults: true })
break;
}
- case 'compress-and-organise': {compressAndOrganise(); break}
- case 'purge-file': { deleteFile(currentFile); break }
-
+
+ // Help Menu
case 'keyboardHelp': { (async () => await populateHelpModal('Help/keyboard.html', 'Keyboard shortcuts'))(); break }
case 'settingsHelp': { (async () => await populateHelpModal('Help/settings.html', 'Settings Help'))(); break }
case 'usage': { (async () => await populateHelpModal('Help/usage.html', 'Usage Guide'))(); break }
case 'bugs': { (async () => await populateHelpModal('Help/bugs.html', 'Join the Chirpity Users community'))(); break }
- case 'species': { worker.postMessage({action: 'get-valid-species', file: currentFile}); break }
+ case 'species': { worker.postMessage({action: 'get-valid-species', file: STATE.currentFile}); break }
case 'startTour': { prepTour(); break }
case 'eBird': { (async () => await populateHelpModal('Help/ebird.html', 'eBird Record FAQ'))(); break }
+
+ // --- Backends
+ case 'tensorflow':
+ case 'webgl':
+ case 'webgpu':{
+ if (PREDICTING){
+ generateToast({message: 'It is not possible to change the model backend while an analysis is underway', type:'warning'})
+ document.getElementById(config[config.model].backend).checked = true;
+ } else {
+ handleBackendChange(target);
+ }
+ break;
+ }
+
case 'archive-location-select': {
(async () =>{
const files = await window.electron.selectDirectory(config.archive.location)
@@ -4713,9 +4800,6 @@ DOM.gain.addEventListener('input', () => {
} else {
DOM.batchSizeValue.textContent = BATCH_SIZE_LIST[DOM.batchSizeSlider.value].toString();
config[config[config.model].backend].batchSize = BATCH_SIZE_LIST[element.value];
- // Need this in case a non-default batchsize was set, and then changed to 32
- if (config[config.model].backend === 'tensorflow') config.tensorflow.batchSizeWasReset = true;
-
worker.postMessage({action: 'change-batch-size', batchSize: BATCH_SIZE_LIST[element.value]})
// Reset region maxLength
initRegion();
@@ -4730,7 +4814,7 @@ DOM.gain.addEventListener('input', () => {
} else {
colorMapFieldset.classList.add('d-none')
}
- if (wavesurfer && currentFile) {
+ if (wavesurfer && STATE.currentFile) {
const fftSamples = wavesurfer.spectrogram.fftSamples;
wavesurfer.destroy();
wavesurfer = undefined;
@@ -4746,7 +4830,7 @@ DOM.gain.addEventListener('input', () => {
const threshold = document.getElementById('color-threshold-slider').valueAsNumber;
document.getElementById('color-threshold').textContent = threshold;
config.customColormap = {'loud': loud, 'mid': mid, 'quiet': quiet, 'threshold': threshold, 'windowFn': windowFn};
- if (wavesurfer && currentFile) {
+ if (wavesurfer && STATE.currentFile) {
const fftSamples = wavesurfer.spectrogram.fftSamples;
wavesurfer.destroy();
wavesurfer = undefined;
@@ -4765,7 +4849,7 @@ DOM.gain.addEventListener('input', () => {
}
case 'spec-labels': {
config.specLabels = element.checked;
- if (wavesurfer && currentFile) {
+ if (wavesurfer && STATE.currentFile) {
const fftSamples = wavesurfer.spectrogram.fftSamples;
wavesurfer.destroy();
wavesurfer = undefined;
@@ -4921,9 +5005,9 @@ async function readLabels(labelFile, updating){
// If we haven't clicked the active row or we cleared the region, load the row we clicked
if (resultContext || hideInSelection || hideInSummary) {
// Lets check if the summary needs to be filtered
- if (!(inSummary && target.closest('tr').classList.contains('text-warning'))) {
+ if (inSummary && ! target.closest('tr').classList.contains('text-warning')) {
target.click(); // Wait for file to load
- await waitForFileLoad();
+ await waitFor(() => fileLoaded);
}
}
if (region === undefined && ! inSummary) return;
@@ -5069,7 +5153,7 @@ async function readLabels(labelFile, updating){
const insertManualRecord = (cname, start, end, comment, count, label, action, batch, originalCname, confidence) => {
- const files = batch ? fileList : currentFile;
+ const files = batch ? STATE.openFiles : STATE.currentFile;
worker.postMessage({
action: 'insert-manual-record',
cname: cname,
@@ -5127,7 +5211,7 @@ async function readLabels(labelFile, updating){
function deleteFile(file) {
// EventHandler caller
if (typeof file === 'object' && file instanceof Event) {
- file = currentFile;
+ file = STATE.currentFile;
}
if (file) {
if (confirm(`This will remove ${file} and all the associated detections from the database archive. Proceed?`)) {
@@ -5147,23 +5231,23 @@ async function readLabels(labelFile, updating){
})
}
- // Utility functions to wait for file to load
- function delay(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- async function waitForFileLoad() {
- while (!fileLoaded) {
- await delay(100); // Wait for 100 milliseconds before checking again
- }
- }
-
- async function waitForLocations() {
- while (!LOCATIONS) {
- await delay(100); // Wait for 100 milliseconds before checking again
- }
- return;
+ // Utility functions to wait for a variable to not be falsey
+
+ let retryCount = 0
+ function waitFor(checkFn) {
+ let maxRetries = 15;
+ return new Promise((resolve) => {
+ let interval = setInterval(() => {
+ if (checkFn() || retryCount >= maxRetries) {
+ clearInterval(interval); // Stop further retries
+ resolve(retryCount = 0); // Resolve the promise
+ } else {
+ console.log('retries: ', ++retryCount);
+ }
+ }, 100);
+ });
}
+
// TOUR functions
const tourModal = document.getElementById('tourModal');
@@ -5233,7 +5317,7 @@ async function readLabels(labelFile, updating){
const example_file = await window.electron.getAudio();
// create a canvas for the audio spec
showElement(['spectrogramWrapper'], false);
- await loadAudioFile({ filePath: example_file });
+ loadAudioFile({ filePath: example_file });
}
startTour();
}
@@ -5255,7 +5339,7 @@ async function readLabels(labelFile, updating){
// CI functions
const getFileLoaded = () => fileLoaded;
const donePredicting = () => !PREDICTING;
- const getAudacityLabels = () => AUDACITY_LABELS[currentFile];
+ const getAudacityLabels = () => AUDACITY_LABELS[STATE.currentFile];
// Update checking for Mac
diff --git a/js/worker.js b/js/worker.js
index 151d0553..eb99a65a 100644
--- a/js/worker.js
+++ b/js/worker.js
@@ -14,8 +14,9 @@ const merge = require('lodash.merge');
import { State } from './state.js';
import { sqlite3 } from './database.js';
import {trackEvent} from './tracking.js';
+import {extractWaveMetadata} from './metadata.js';
-const DEBUG = true;
+const DEBUG = false;
// Function to join Buffers and not use Buffer.concat() which leads to detached ArrayBuffers
function joinBuffers(buffer1, buffer2) {
@@ -91,7 +92,7 @@ const SUPPORTED_FILES = ['.wav', '.flac', '.opus', '.m4a', '.mp3', '.mpga', '.og
let NUM_WORKERS;
let workerInstance = 0;
-let appPath, BATCH_SIZE, LABELS, batchChunksToSend = {};
+let appPath, tempPath, BATCH_SIZE, LABELS, batchChunksToSend = {};
let LIST_WORKER;
const DATASET = false;
@@ -117,7 +118,57 @@ let diskDB, memoryDB;
let t0; // Application profiler
+const setupFfmpegCommand = ({
+ file,
+ start = 0,
+ end = undefined,
+ sampleRate = 24000,
+ channels = 1,
+ format = 'wav',
+ additionalFilters = [],
+ metadata = {},
+ audioCodec = null,
+ audioBitrate = null,
+ outputOptions = []
+}) => {
+ const command = ffmpeg('file:' + file)
+ .seekInput(start)
+ .duration(end - start)
+ .format(format)
+ .audioChannels(channels)
+ .audioFrequency(sampleRate);
+
+ // Add filters if provided
+ additionalFilters.forEach(filter => {
+ command.audioFilters(filter);
+ });
+ if (Object.keys(metadata).length) {
+ metadata = Object.entries(metadata).flatMap(([k, v]) => {
+ if (typeof v === 'string') {
+ // Escape special characters, including quotes and apostrophes
+ v=v.replaceAll(' ', '_');
+ };
+ return ['-metadata', `${k}=${v}`]
+ });
+ command.addOutputOptions(metadata)
+ }
+ // Set codec if provided
+ if (audioCodec) command.audioCodec(audioCodec);
+
+ // Set bitRate if provided
+ if (audioBitrate) command.audioBitrate(audioBitrate);
+
+ // Add any additional output options
+ if (outputOptions.length) command.addOutputOptions(...outputOptions);
+
+ if (DEBUG){
+ command.on('start', function (commandLine) {
+ console.log('FFmpeg command: ' + commandLine);
+ })
+ }
+ return command;
+};
const getSelectionRange = (file, start, end) => {
@@ -374,6 +425,7 @@ async function handleMessage(e) {
let {model, batchSize, threads, backend, list} = args;
const t0 = Date.now();
STATE.detect.backend = backend;
+ setGetSummaryQueryInterval(threads)
INITIALISED = (async () => {
LIST_WORKER = await spawnListWorker(); // this can change the backend if tfjs-node isn't available
DEBUG && console.log('List worker took', Date.now() - t0, 'ms to load');
@@ -407,6 +459,7 @@ async function handleMessage(e) {
}
case 'change-threads': {
const threads = e.data.threads;
+ setGetSummaryQueryInterval(threads)
const delta = threads - predictWorkers.length;
NUM_WORKERS+=delta;
if (delta > 0) {
@@ -459,15 +512,11 @@ async function handleMessage(e) {
}
case "filter": {
if (STATE.db) {
- t0 = Date.now();
await getResults(args);
- const t1 = Date.now();
args.updateSummary && await getSummary(args);
- const t2 = Date.now();
args.included = await getIncludedIDs(args.file);
const [total, offset, species] = await getTotal(args);
UI.postMessage({event: 'total-records', total: total, offset: offset, species: species})
- DEBUG && console.log("Filter took", (Date.now() - t0) / 1000, "seconds", "GetTotal took", (Date.now() - t2) / 1000, "seconds", "GetSummary took", (t2 - t1) / 1000, "seconds");
}
break;
}
@@ -542,7 +591,7 @@ async function handleMessage(e) {
break;
}
case "update-state": {
- appPath = args.path || appPath;
+ appPath = args.path || appPath; tempPath = args.temp || tempPath;
// If we change the speciesThreshold, we need to invalidate any location caches
if (args.speciesThreshold) {
if (STATE.included?.['birdnet']?.['location']) STATE.included.birdnet.location = {};
@@ -586,6 +635,10 @@ function savedFileCheck(fileList) {
}
}
+function setGetSummaryQueryInterval(threads){
+ STATE.incrementor = STATE.detect.backend !== 'tensorflow' ? threads * 10 : threads;
+}
+
async function onChangeMode(mode) {
memoryDB || await createDB();
UI.postMessage({ event: 'mode-changed', mode: mode })
@@ -1075,7 +1128,14 @@ async function locateFile(file) {
}
}
} catch (error) {
- console.warn(error.message); // Expected that this happens when the directory doesn't exist
+ if (error.message.includes('scandir')){
+ const match = error.message.match(/'([^']+)'/);
+ UI.postMessage({
+ event: 'generate-alert', type: 'warning',
+ message: `Unable to locate folder "${match}". Perhaps the disk was removed?`
+ })
+ }
+ console.warn(error.message + ' - Disk removed?'); // Expected that this happens when the directory doesn't exist
}
return null;
}
@@ -1086,7 +1146,7 @@ async function notifyMissingFile(file) {
const row = await diskDB.getAsync('SELECT * FROM FILES WHERE name = ?', file);
if (row?.id) missingFile = file
UI.postMessage({
- event: 'generate-alert', type: 'error',
+ event: 'generate-alert', type: 'warning',
message: `Unable to locate source file with any supported file extension: ${file}`,
file: missingFile
})
@@ -1122,7 +1182,7 @@ async function loadAudioFile({
play: play,
queued: queued,
goToRegion,
- guano: METADATA[file].guano
+ metadata: METADATA[file].metadata
}, [audioArray.buffer]);
})
.catch( (error) => {
@@ -1175,12 +1235,10 @@ const setMetadata = async ({ file, source_file = file }) => {
throw new Error('Unable to determine file duration ', e);
}
if (file.toLowerCase().endsWith('wav')){
- const {extractGuanoMetadata} = await import('./guano.js').catch(error => {
- console.warn('Error loading guano.js', error)}
- );
const t0 = Date.now();
- const guano = await extractGuanoMetadata(file).catch(error => console.warn("Error extracting GUANO", error));
- if (guano){
+ const wavMetadata = await extractWaveMetadata(file).catch(error => console.warn("Error extracting GUANO", error));
+ if (Object.keys(wavMetadata).includes('guano')){
+ const guano = wavMetadata.guano;
const location = guano['Loc Position'];
if (location){
const [lat, lon] = location.split(' ');
@@ -1189,7 +1247,9 @@ const setMetadata = async ({ file, source_file = file }) => {
}
guanoTimestamp = Date.parse(guano.Timestamp);
METADATA[file].fileStart = guanoTimestamp;
- METADATA[file].guano = JSON.stringify(guano);
+ }
+ if (Object.keys(wavMetadata).length > 0){
+ METADATA[file].metadata = JSON.stringify(wavMetadata);
}
DEBUG && console.log(`GUANO search took: ${(Date.now() - t0)/1000} seconds`);
}
@@ -1281,8 +1341,7 @@ function setupCtx(audio, rate, destination, file) {
.catch(error => aborted || console.warn(error, file));
};
-
-function checkBacklog(stream) {
+function checkBacklog() {
return new Promise((resolve) => {
const backlog = sumObjectValues(predictionsRequested) - sumObjectValues(predictionsReceived);
DEBUG && console.log('backlog:', backlog);
@@ -1290,10 +1349,10 @@ function checkBacklog(stream) {
if (backlog >= predictWorkers.length * 2) {
// If backlog is too high, check again after a short delay
setTimeout(() => {
- resolve(checkBacklog(stream)); // Recursively call until backlog is within limits
+ resolve(checkBacklog()); // Recursively call until backlog is within limits
}, 50);
} else {
- resolve(stream.read()); // Backlog ok, read the stream data
+ resolve(); // Backlog ok, read the stream data
}
});
}
@@ -1364,24 +1423,23 @@ const getWavePredictBuffers = async ({
let chunkStart = start * sampleRate;
// Changed on.('data') handler because of: https://stackoverflow.com/questions/32978094/nodejs-streams-and-premature-end
- readStream.on('readable', () => {
+ readStream.on('readable', async () => {
if (aborted) {
readStream.destroy();
return
}
-
- checkBacklog(readStream).then(chunk => {
- if (chunk === null || chunk.byteLength <= 1 ) {
- // EOF
- chunk?.byteLength && predictionsReceived[file]++;
- readStream.destroy();
- } else {
- const audio = joinBuffers(meta.header, chunk);
- predictQueue.push([audio, file, end, chunkStart]);
- chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate;
- processPredictQueue();
- }
- })
+ await checkBacklog();
+ const chunk = readStream.read();
+ if (chunk === null || chunk.byteLength <= 1 ) {
+ // EOF
+ chunk?.byteLength && predictionsReceived[file]++;
+ readStream.destroy();
+ } else {
+ const audio = joinBuffers(meta.header, chunk);
+ predictQueue.push([audio, file, end, chunkStart]);
+ chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate;
+ processPredictQueue();
+ }
})
readStream.on('error', err => {
console.log(`readstream error: ${err}, start: ${start}, , end: ${end}, duration: ${METADATA[file].duration}`);
@@ -1455,24 +1513,54 @@ const getPredictBuffers = async ({
if (start > METADATA[file].duration) {
return
}
- let header, shortFile = true;
+
const MINIMUM_AUDIO_LENGTH = 0.05; // below this value doesn't generate another chunk
batchChunksToSend[file] = Math.ceil((end - start - MINIMUM_AUDIO_LENGTH) / (BATCH_SIZE * WINDOW_SIZE));
predictionsReceived[file] = 0;
predictionsRequested[file] = 0;
- let highWaterMark = 2 * sampleRate * BATCH_SIZE * WINDOW_SIZE;
-
+ const samplesInBatch = sampleRate * BATCH_SIZE * WINDOW_SIZE
+ const highWaterMark = samplesInBatch * 2;
+
let chunkStart = start * sampleRate;
+ if (STATE.detect.backend === 'tensorflow'){
+ const step = (BATCH_SIZE * WINDOW_SIZE);
+ // Throttle the ingest of transcoded audio
+ for (let i = start; i < end; i += step){
+ await checkBacklog();
+ const finish = i + step;
+ await processAudio(file, i, finish, chunkStart, highWaterMark)
+ chunkStart += samplesInBatch;
+ }
+ } else {
+ // Full gas
+ await processAudio(file, start, end, chunkStart, highWaterMark, samplesInBatch)
+ }
+}
+
+function lookForHeader(buffer){
+ //if (buffer.length < 4096) return undefined
+ try {
+ const wav = new wavefileReader.WaveFileReader();
+ wav.fromBuffer(buffer);
+ let headerEnd;
+ wav.signature.subChunks.forEach(el => {
+ if (el['chunkId'] === 'data') {
+ headerEnd = el.chunkData.start;
+ }
+ });
+ return buffer.subarray(0, headerEnd);
+ } catch (e) {
+ DEBUG && console.log(e)
+ return undefined
+ }
+}
+
+function processAudio (file, start, end, chunkStart, highWaterMark, samplesInBatch){
+ let header, shortFile = true;
return new Promise((resolve, reject) => {
let concatenatedBuffer = Buffer.alloc(0);
- const command = ffmpeg('file:' + file)
- .seekInput(start)
- .duration(end - start)
- .format('wav')
- .audioChannels(1) // Set to mono
- .audioFrequency(sampleRate) // Set sample rate
-
+ const command = setupFfmpegCommand({file, start, end, sampleRate})
command.on('error', (error) => {
updateFilesBeingProcessed(file)
if (error.message.includes('SIGKILL')) console.log('FFMPEG process shut down at user request')
@@ -1482,9 +1570,6 @@ const getPredictBuffers = async ({
console.log('Ffmpeg error in file:\n', file, 'stderr:\n', error)
reject(console.warn('getPredictBuffers: Error in ffmpeg extracting audio segment:', error));
});
- command.on('start', function (commandLine) {
- DEBUG && console.log('FFmpeg command: ' + commandLine);
- })
const STREAM = command.pipe();
STREAM.on('readable', () => {
@@ -1495,20 +1580,19 @@ const getPredictBuffers = async ({
const chunk = STREAM.read();
if (chunk === null) {
//EOF: deal with part-full buffers
- if (shortFile) highWaterMark -= header.length;
+ // if (shortFile) highWaterMark -= header.length;
if (concatenatedBuffer.byteLength){
header || console.warn('no header for ' + file)
let noHeader;
- if (concatenatedBuffer.length < header.length) noHeader = true;
- else noHeader = concatenatedBuffer.compare(header, 0, header.length, 0, header.length)
- const audio = noHeader ? joinBuffers(header, concatenatedBuffer) : concatenatedBuffer;
+ if (concatenatedBuffer.length < header.length) {noHeader = true}
+ else {noHeader = concatenatedBuffer.compare(header, 0, header.length, 0, header.length)} //compare returns 0 when there is a header in audio_chunk!
+ const audio = noHeader ? Buffer.concat([header, concatenatedBuffer]) : concatenatedBuffer;
processPredictQueue(audio, file, end, chunkStart);
} else {
updateFilesBeingProcessed(file)
}
DEBUG && console.log('All chunks sent for ', file);
- //STREAM.end();
- resolve('finished')
+ resolve()
}
else {
concatenatedBuffer = concatenatedBuffer.length ? joinBuffers(concatenatedBuffer, chunk) : chunk;
@@ -1519,25 +1603,16 @@ const getPredictBuffers = async ({
// Initally, the highwatermark needs to add the header length to get the correct length of audio
if (header) highWaterMark += header.length;
}
-
// if we have a full buffer
if (concatenatedBuffer.length > highWaterMark) {
- const audio_chunk = Buffer.allocUnsafeSlow(highWaterMark);
- concatenatedBuffer.copy(audio_chunk, 0, 0, highWaterMark);
- const remainder = Buffer.allocUnsafeSlow(concatenatedBuffer.length - highWaterMark);
- concatenatedBuffer.copy(remainder, 0, highWaterMark);
- const noHeader = audio_chunk.compare(header, 0, header.length, 0, header.length)
- const audio = noHeader ? joinBuffers(header, audio_chunk) : audio_chunk;
- // If we *do* have a header, we need to reset highwatermark because subsequent chunks *won't* have it
- if (! noHeader) {
- highWaterMark -= header.length;
- shortFile = false;
- }
+ const audio_chunk = concatenatedBuffer.subarray(0, highWaterMark);
+ const remainder = concatenatedBuffer.subarray(highWaterMark);
+ let noHeader = concatenatedBuffer.compare(header, 0, header.length, 0, header.length)
+ const audio = noHeader ? Buffer.concat([header, audio_chunk]) : audio_chunk;
processPredictQueue(audio, file, end, chunkStart);
- chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate
+ chunkStart += samplesInBatch;
concatenatedBuffer = remainder;
-
}
}
});
@@ -1550,24 +1625,6 @@ const getPredictBuffers = async ({
}).catch(error => console.log(error));
}
-function lookForHeader(buffer){
- //if (buffer.length < 4096) return undefined
- try {
- const wav = new wavefileReader.WaveFileReader();
- wav.fromBuffer(buffer);
- let headerEnd;
- wav.signature.subChunks.forEach(el => {
- if (el['chunkId'] === 'data') {
- headerEnd = el.chunkData.start;
- }
- });
- return buffer.subarray(0, headerEnd);
- } catch (e) {
- DEBUG && console.log(e)
- return undefined
- }
-}
-
/**
* Called when file first loaded, when result clicked and when saving or sending file snippets
* @param args
@@ -1590,43 +1647,34 @@ const fetchAudioBuffer = async ({
// Use ffmpeg to extract the specified audio segment
if (isNaN(start)) throw(new Error('fetchAudioBuffer: start is NaN'));
return new Promise((resolve, reject) => {
- let command = ffmpeg('file:' + file)
- .seekInput(start)
- .duration(end - start)
- .format('wav')
- .audioChannels(1) // Set to mono
- .audioFrequency(24_000) // Set sample rate to 24000 Hz (always - this is for wavesurfer)
- if (STATE.filters.active) {
- if (STATE.filters.lowShelfAttenuation && STATE.filters.lowShelfFrequency){
- command.audioFilters({
- filter: 'lowshelf',
- options: `gain=${STATE.filters.lowShelfAttenuation}:f=${STATE.filters.lowShelfFrequency}`
- })
- }
- if (STATE.filters.highPassFrequency){
- command.audioFilters({
- filter: 'highpass',
- options: `f=${STATE.filters.highPassFrequency}:poles=1`
- })
+ const command = setupFfmpegCommand({
+ file,
+ start,
+ end,
+ sampleRate: 24000,
+ format: 'wav',
+ channels: 1,
+ additionalFilters: [
+ STATE.filters.lowShelfAttenuation && {
+ filter: 'lowshelf',
+ options: `gain=${STATE.filters.lowShelfAttenuation}:f=${STATE.filters.lowShelfFrequency}`
+ },
+ STATE.filters.highPassFrequency && {
+ filter: 'highpass',
+ options: `f=${STATE.filters.highPassFrequency}:poles=1`
+ },
+ STATE.audio.normalise && {
+ filter: 'loudnorm',
+ options: "I=-16:LRA=11:TP=-1.5"
}
- }
- if (STATE.audio.normalise){
- command.audioFilters(
- {
- filter: 'loudnorm',
- options: "I=-16:LRA=11:TP=-1.5"
- }
- )
- }
+ ].filter(Boolean),
+ });
const stream = command.pipe();
command.on('error', error => {
UI.postMessage({event: 'generate-alert', type: 'error', message: error})
reject(new Error('fetchAudioBuffer: Error extracting audio segment:', error));
});
- command.on('start', function (commandLine) {
- DEBUG && console.log('FFmpeg command: ' + commandLine);
- })
stream.on('readable', () => {
const chunk = stream.read();
@@ -1679,7 +1727,6 @@ async function feedChunksToModel(channelData, chunkStart, file, end, worker) {
if (predictWorkers[worker]) predictWorkers[worker].isAvailable = false;
predictWorkers[worker]?.postMessage(objData, [channelData.buffer]);
}
-
async function doPrediction({
file = '',
start = 0,
@@ -1873,51 +1920,26 @@ async function uploadOpus({ file, start, end, defaultName, metadata, mode }) {
}
const bufferToAudio = async ({
- file = '', start = 0, end = 3, meta = {}, format = undefined
+ file = '', start = 0, end = 3, meta = {}, format = undefined, folder = undefined, filename = undefined
}) => {
if (! fs.existsSync(file)) {
const found = await getWorkingFile(file);
if (!found) return
}
- let audioCodec, mimeType, soundFormat;
let padding = STATE.audio.padding;
let fade = STATE.audio.fade;
let bitrate = STATE.audio.bitrate;
let quality = parseInt(STATE.audio.quality);
let downmix = STATE.audio.downmix;
format ??= STATE.audio.format;
- const bitrateMap = { 24_000: '24k', 16_000: '16k', 12_000: '12k', 8000: '8k', 44_100: '44k', 22_050: '22k', 11_025: '11k' };
- if (format === 'mp3') {
- audioCodec = 'libmp3lame';
- soundFormat = 'mp3';
- mimeType = 'audio/mpeg'
- } else if (format === 'wav') {
- audioCodec = 'pcm_s16le';
- soundFormat = 'wav';
- mimeType = 'audio/wav'
- } else if (format === 'flac') {
- audioCodec = 'flac';
- soundFormat = 'flac';
- mimeType = 'audio/flac'
- // Static binary is missing the aac encoder
- // } else if (format === 'm4a') {
- // audioCodec = 'aac';
- // soundFormat = 'aac';
- // mimeType = 'audio/mp4'
- } else if (format === 'opus') {
- audioCodec = 'libopus';
- soundFormat = 'opus'
- mimeType = 'audio/ogg'
- }
+ const formatMap = {
+ mp3: { audioCodec: 'libmp3lame', soundFormat: 'mp3' },
+ wav: { audioCodec: 'pcm_s16le', soundFormat: 'wav' },
+ flac: { audioCodec: 'flac', soundFormat: 'flac' },
+ opus: { audioCodec: 'libopus', soundFormat: 'opus' }
+ };
+ const { audioCodec, soundFormat } = formatMap[format] || {};
- let optionList = [];
- for (let [k, v] of Object.entries(meta)) {
- if (typeof v === 'string') {
- v = v.replaceAll(' ', '_');
- }
- optionList.push('-metadata');
- optionList.push(`${k}=${v}`);
- }
METADATA[file] || await getWorkingFile(file);
if (padding) {
start -= padding;
@@ -1927,53 +1949,46 @@ const bufferToAudio = async ({
}
return new Promise(function (resolve, reject) {
- const bufferStream = new PassThrough();
- let ffmpgCommand = ffmpeg('file:' + file)
+ let command = ffmpeg('file:' + file)
.toFormat(soundFormat)
- .seekInput(start)
- .duration(end - start)
.audioChannels(downmix ? 1 : -1)
// I can't get this to work with Opus
// .audioFrequency(METADATA[file].sampleRate)
- .audioCodec(audioCodec)
- .addOutputOptions(...optionList)
-
+ .audioCodec(audioCodec).seekInput(start).duration(end - start)
if (['mp3', 'm4a', 'opus'].includes(format)) {
//if (format === 'opus') bitrate *= 1000;
- ffmpgCommand = ffmpgCommand.audioBitrate(bitrate)
+ command = command.audioBitrate(bitrate)
} else if (['flac'].includes(format)) {
- ffmpgCommand = ffmpgCommand.audioQuality(quality)
+ command = command.audioQuality(quality)
}
if (STATE.filters.active) {
- if (STATE.filters.lowShelfFrequency > 0){
- ffmpgCommand = ffmpgCommand.audioFilters(
- {
- filter: 'lowshelf',
- options: `gain=${STATE.filters.lowShelfAttenuation}:f=${STATE.filters.lowShelfFrequency}`
- }
- )
- }
- if (STATE.filters.highPassFrequency > 0){
- ffmpgCommand = ffmpgCommand.audioFilters(
- {
- filter: 'highpass',
- options: `f=${STATE.filters.highPassFrequency}:poles=1`
- }
- )
- }
- if (STATE.audio.normalise){
- ffmpgCommand = ffmpgCommand.audioFilters(
- {
- filter: 'loudnorm',
- options: "I=-16:LRA=11:TP=-1.5" //:offset=" + STATE.audio.gain
- }
- )
+ const filters = [];
+ if (STATE.filters.lowShelfFrequency > 0) {
+ filters.push({
+ filter: 'lowshelf',
+ options: `gain=${STATE.filters.lowShelfAttenuation}:f=${STATE.filters.lowShelfFrequency}`
+ });
}
+ if (STATE.filters.highPassFrequency > 0) {
+ filters.push({
+ filter: 'highpass',
+ options: `f=${STATE.filters.highPassFrequency}:poles=1`
+ });
+ }
+ if (STATE.audio.normalise) {
+ filters.push({
+ filter: 'loudnorm',
+ options: "I=-16:LRA=11:TP=-1.5"
+ });
+ }
+ if (filters.length > 0) {
+ command = command.audioFilters(filters);
+ }
}
if (fade && padding) {
const duration = end - start;
if (start >= 1 && end <= METADATA[file].duration - 1) {
- ffmpgCommand = ffmpgCommand.audioFilters(
+ command = command.audioFilters(
{
filter: 'afade',
options: `t=in:ss=${start}:d=1`
@@ -1984,47 +1999,41 @@ const bufferToAudio = async ({
}
)}
}
+ if (Object.entries(meta).length){
+ meta = Object.entries(meta).flatMap(([k, v]) => {
+ if (typeof v === 'string') {
+ // Escape special characters, including quotes and apostrophes
+ v=v.replaceAll(' ', '_');
+ };
+ return ['-metadata', `${k}=${v}`]
+ });
+ command.addOutputOptions(meta)
+ }
+ //const destination = p.join((folder || tempPath), 'file.mp3');
+ const destination = p.join((folder || tempPath), filename);
+ command.save(destination);
-
- ffmpgCommand.on('start', function (commandLine) {
+ command.on('start', function (commandLine) {
DEBUG && console.log('FFmpeg command: ' + commandLine);
})
- ffmpgCommand.on('error', (err) => {
+ command.on('error', (err) => {
console.log('An error occurred: ' + err.message);
})
- ffmpgCommand.on('end', function () {
+ command.on('end', function () {
DEBUG && console.log(format + " file rendered")
+ resolve(destination)
})
- ffmpgCommand.writeToStream(bufferStream);
-
- let concatenatedBuffer = Buffer.alloc(0);
- bufferStream.on('readable', () => {
- const chunk = bufferStream.read();
- if (chunk === null){
- let audio = [];
- audio.push(new Int8Array(concatenatedBuffer))
- const blob = new Blob(audio, { type: mimeType });
- resolve(blob);
- } else {
- concatenatedBuffer = concatenatedBuffer.length ? joinBuffers(concatenatedBuffer, chunk) : chunk;
- }
- });
})
};
async function saveAudio(file, start, end, filename, metadata, folder) {
- const thisBlob = await bufferToAudio({
- file: file, start: start, end: end, meta: metadata
+ filename = filename.replaceAll(':', '-');
+ const convertedFilePath = await bufferToAudio({
+ file: file, start: start, end: end, meta: metadata, folder: folder, filename: filename
});
- if (folder) {
- const buffer = Buffer.from(await thisBlob.arrayBuffer());
- if (! fs.existsSync(folder)) fs.mkdirSync(folder, {recursive: true});
- fs.writeFile(p.join(folder, filename), buffer, {flag: 'w'}, err => {
- if (err) console.log(err) ;
- else if (DEBUG) console.log('Audio file saved') });
- }
+ if (folder && DEBUG) { console.log('Audio file saved: ', convertedFilePath) }
else {
- UI.postMessage({event:'audio-file-to-save', file: thisBlob, filename: filename})
+ UI.postMessage({event:'audio-file-to-save', file: convertedFilePath, filename: filename, extension: STATE.audio.format})
}
}
@@ -2153,7 +2162,7 @@ const onInsertManualRecord = async ({ cname, start, end, comment, count, file, l
// Manual records can be added off the bat, so there may be no record of the file in either db
fileStart = METADATA[file].fileStart;
res = await db.runAsync('INSERT OR IGNORE INTO files VALUES ( ?,?,?,?,?,? )',
- fileID, file, METADATA[file].duration, fileStart, undefined, undefined, METADATA[file].guano);
+ fileID, file, METADATA[file].duration, fileStart, undefined, undefined, METADATA[file].metadata);
fileID = res.lastID;
changes = 1;
let durationSQL = Object.entries(METADATA[file].dateDuration)
@@ -2199,9 +2208,10 @@ const generateInsertQuery = async (latestResult, file) => {
let res = await db.getAsync('SELECT id FROM files WHERE name = ?', file);
if (!res) {
let id = null;
- if (METADATA[file].guano){
- const guano = JSON.parse(METADATA[file].guano);
- if (guano['Loc Position']){
+ if (METADATA[file].metadata){
+ const metadata = JSON.parse(METADATA[file].metadata);
+ const guano = metadata.guano;
+ if (guano && guano['Loc Position']){
const [lat, lon] = guano['Loc Position'].split(' ');
const place = guano['Site Name'] || guano['Loc Position'];
const row = await db.getAsync('SELECT id FROM locations WHERE lat = ? AND lon = ?', parseFloat(lat), parseFloat(lon))
@@ -2213,7 +2223,7 @@ const generateInsertQuery = async (latestResult, file) => {
}
}
res = await db.runAsync('INSERT OR IGNORE INTO files VALUES ( ?,?,?,?,?,?,? )',
- undefined, file, METADATA[file].duration, METADATA[file].fileStart, id, null, METADATA[file].guano);
+ undefined, file, METADATA[file].duration, METADATA[file].fileStart, id, null, METADATA[file].metadata);
fileID = res.lastID;
await insertDurations(file, fileID);
} else {
@@ -2229,7 +2239,7 @@ const generateInsertQuery = async (latestResult, file) => {
const speciesIDArray = speciesIDBatch[i];
for (let j = 0; j < confidenceArray.length; j++) {
const confidence = Math.round(confidenceArray[j] * 1000);
- if (confidence < 50) break;
+ if (confidence < STATE.detect.confidence) break;
const speciesID = speciesIDArray[j];
insertQuery += `(${timestamp}, ${key}, ${fileID}, ${speciesID}, ${confidence}, null, null, ${key + 3}, null, ${isDaylight}), `;
}
@@ -2246,53 +2256,55 @@ const generateInsertQuery = async (latestResult, file) => {
const parsePredictions = async (response) => {
let file = response.file;
- const included = await getIncludedIDs(file).catch( (error) => console.log('Error getting included IDs', error));
- const latestResult = response.result, db = STATE.db;
+
+ const latestResult = response.result;
DEBUG && console.log('worker being used:', response.worker);
if (! STATE.selection) await generateInsertQuery(latestResult, file).catch(error => console.warn('Error generating insert query', error));
let [keysArray, speciesIDBatch, confidenceBatch] = latestResult;
- for (let i = 0; i < keysArray.length; i++) {
- let updateUI = false;
- let key = parseFloat(keysArray[i]);
- const timestamp = METADATA[file].fileStart + key * 1000;
- const confidenceArray = confidenceBatch[i];
- const speciesIDArray = speciesIDBatch[i];
- for (let j = 0; j < confidenceArray.length; j++) {
- let confidence = confidenceArray[j];
- if (confidence < 0.05) break;
- confidence*=1000;
- let speciesID = speciesIDArray[j];
- updateUI = (confidence > STATE.detect.confidence && (! included.length || included.includes(speciesID)));
- if (STATE.selection || updateUI) {
- let end, confidenceRequired;
- if (STATE.selection) {
- const duration = (STATE.selection.end - STATE.selection.start) / 1000;
- end = key + duration;
- confidenceRequired = STATE.userSettingsInSelection ?
- STATE.detect.confidence : 50;
- } else {
- end = key + 3;
- confidenceRequired = STATE.detect.confidence;
- }
- if (confidence >= confidenceRequired) {
- const { cname, sname } = await memoryDB.getAsync(`SELECT cname, sname FROM species WHERE id = ${speciesID}`).catch(error => console.warn('Error getting species name', error));
- const result = {
- timestamp: timestamp,
- position: key,
- end: end,
- file: file,
- cname: cname,
- sname: sname,
- score: confidence
+ if (index < 499){
+ const included = await getIncludedIDs(file).catch( (error) => console.log('Error getting included IDs', error));
+ for (let i = 0; i < keysArray.length; i++) {
+ let updateUI = false;
+ let key = parseFloat(keysArray[i]);
+ const timestamp = METADATA[file].fileStart + key * 1000;
+ const confidenceArray = confidenceBatch[i];
+ const speciesIDArray = speciesIDBatch[i];
+ for (let j = 0; j < confidenceArray.length; j++) {
+ let confidence = confidenceArray[j];
+ if (confidence < 0.05) break;
+ confidence*=1000;
+ let speciesID = speciesIDArray[j];
+ updateUI = (confidence > STATE.detect.confidence && (! included.length || included.includes(speciesID)));
+ if (STATE.selection || updateUI) {
+ let end, confidenceRequired;
+ if (STATE.selection) {
+ const duration = (STATE.selection.end - STATE.selection.start) / 1000;
+ end = key + duration;
+ confidenceRequired = STATE.userSettingsInSelection ?
+ STATE.detect.confidence : 50;
+ } else {
+ end = key + 3;
+ confidenceRequired = STATE.detect.confidence;
}
- sendResult(++index, result, false);
- // Only show the highest confidence detection, unless it's a selection analysis
- if (! STATE.selection) break;
- };
+ if (confidence >= confidenceRequired) {
+ const { cname, sname } = await memoryDB.getAsync(`SELECT cname, sname FROM species WHERE id = ${speciesID}`).catch(error => console.warn('Error getting species name', error));
+ const result = {
+ timestamp: timestamp,
+ position: key,
+ end: end,
+ file: file,
+ cname: cname,
+ sname: sname,
+ score: confidence
+ }
+ sendResult(++index, result, false);
+ // Only show the highest confidence detection, unless it's a selection analysis
+ if (! STATE.selection) break;
+ };
+ }
}
- }
- }
-
+ }
+ }
predictionsReceived[file]++;
const received = sumObjectValues(predictionsReceived);
const total = sumObjectValues(batchChunksToSend);
@@ -2313,7 +2325,9 @@ const parsePredictions = async (response) => {
updateFilesBeingProcessed(response.file)
DEBUG && console.log(`File ${file} processed after ${(new Date() - predictionStart) / 1000} seconds: ${filesBeingProcessed.length} files to go`);
}
- !STATE.selection && (STATE.increment() === 0) && getSummary({ interim: true });
+
+ !STATE.selection && (STATE.increment() === 0 || index === 1 ) && await getSummary({ interim: true });
+
return response.worker
}
@@ -2427,7 +2441,12 @@ async function processNextFile({
}
function sumObjectValues(obj) {
- return Object.values(obj).reduce((total, value) => total + value, 0);
+ let total = 0;
+ for (const key in obj) {
+ total += obj[key];
+ }
+ return total;
+ //return Object.values(obj).reduce((total, value) => total + value, 0);
}
function onSameDay(timestamp1, timestamp2) {
@@ -2511,12 +2530,11 @@ const getSummary = async ({
interim = false,
action = undefined,
} = {}) => {
- const db = STATE.db;
const included = STATE.selection ? [] : await getIncludedIDs();
const [sql, params] = prepSummaryStatement(included);
const offset = species ? STATE.filteredOffset[species] : STATE.globalOffset;
- t0 = Date.now();
+
const summary = await STATE.db.allAsync(sql, ...params);
const event = interim ? 'update-summary' : 'summary-complete';
UI.postMessage({
@@ -2656,7 +2674,7 @@ const getResults = async ({
//const dateString = new Date(r.timestamp).toISOString().replace(/[TZ]/g, ' ').replace(/\.\d{3}/, '').replace(/[-:]/g, '-').trim();
const filename = `${r.cname}_${dateString}.${STATE.audio.format}`
DEBUG && console.log(`Exporting from ${r.file}, position ${r.position}, into folder ${directory}`)
- saveAudio(r.file, r.position, r.end, filename, metadata, directory)
+ saveAudio(r.file, r.position, r.end, filename, {Artist: 'Chirpity'}, directory)
i === result.length - 1 && UI.postMessage({ event: 'generate-alert', message: `${result.length} files saved` })
}
}
@@ -2678,11 +2696,11 @@ const getResults = async ({
} else {
species = species || '';
const nocmig = STATE.detect.nocmig ? 'nocturnal ' : ''
- sendResult(++index, `No ${nocmig} ${species} detections found using the ${STATE.list} list.`, true)
+ sendResult(++index, `No ${nocmig} ${species} detections found ${STATE.mode === 'explore' ? 'in the Archive' : ''} using the ${STATE.list} list.`, true)
}
}
}
- STATE.selection || UI.postMessage({event: 'database-results-complete', active: active, select: position?.start});
+ (STATE.selection && topRankin === STATE.topRankin) || UI.postMessage({event: 'database-results-complete', active: active, select: position?.start});
};
// Function to format the CSV export
@@ -3706,7 +3724,7 @@ async function convertAndOrganiseFiles(threadLimit) {
const attempted = successfulConversions + failedConversions;
// Create a summary message
let summaryMessage;
- let type = 'notice';
+ let type = 'info';
if (attempted) {
summaryMessage = `Processing complete: ${successfulConversions} successful, ${failedConversions} failed.`;
diff --git a/main.js b/main.js
index 282c026b..697c528a 100644
--- a/main.js
+++ b/main.js
@@ -85,75 +85,70 @@ async function fetchReleaseNotes(version) {
}
+if (! isMac){ // The auto updater doesn't work for .pkg installers
+ autoUpdater.on('checking-for-update', function () {
+ logUpdateStatus('Checking for update...');
+ if (process.env.PORTABLE_EXECUTABLE_DIR){
+ logUpdateStatus('This is a portable exe')
+ }
+ });
+ autoUpdater.on('update-available', async function (info) {
+ if (!process.env.PORTABLE_EXECUTABLE_DIR){
+ autoUpdater.downloadUpdate();
+ } else {
+
+ // Fetch release notes from GitHub API
+ const releaseNotes = await fetchReleaseNotes(info.version);
+ dialog.showMessageBox({
+ type: 'info',
+ title: 'Update Available',
+ message: `A new version (${info.version}) is available.\n\nRelease Notes:\n${releaseNotes}`,
+ buttons: ['OK'],
+ defaultId: 1,
+ noLink: true
+ })
+ }
+ });
-log.transports.file.resolvePathFn = () => path.join(APP_DATA, 'logs/main.log');
-log.info('App starting...');
+ autoUpdater.on('update-not-available', function (info) {
+ logUpdateStatus('Update not available.');
+ });
+ autoUpdater.on('error', function (err) {
+ logUpdateStatus('Error in auto-updater:' + err);
+ });
-autoUpdater.on('checking-for-update', function () {
- logUpdateStatus('Checking for update...');
- if (process.env.PORTABLE_EXECUTABLE_DIR){
- logUpdateStatus('This is a portable exe')
- }
-});
+ autoUpdater.on('download-progress', function (progressObj) {
+ mainWindow.webContents.send('download-progress', progressObj);
+ });
-autoUpdater.on('update-available', async function (info) {
- if (!process.env.PORTABLE_EXECUTABLE_DIR){
- autoUpdater.downloadUpdate();
- } else {
-
+
+ autoUpdater.on('update-downloaded', async function (info) {
// Fetch release notes from GitHub API
const releaseNotes = await fetchReleaseNotes(info.version);
+ log.info(JSON.stringify(info))
+ // Display dialog to the user with release notes
dialog.showMessageBox({
type: 'info',
title: 'Update Available',
- message: `A new version (${info.version}) is available.\n\nRelease Notes:\n${releaseNotes}`,
- buttons: ['OK'],
+ message: `A new version (${info.version}) is available.\n\nRelease Notes:\n${releaseNotes}\n\nDo you want to install it now?`,
+ buttons: ['Quit and Install', 'Install after Exit'],
defaultId: 1,
noLink: true
- })
- }
-});
-
-autoUpdater.on('update-not-available', function (info) {
- logUpdateStatus('Update not available.');
-});
-
-autoUpdater.on('error', function (err) {
- logUpdateStatus('Error in auto-updater:' + err);
-});
-
-autoUpdater.on('download-progress', function (progressObj) {
- mainWindow.webContents.send('download-progress', progressObj);
-});
-
-
-autoUpdater.on('update-downloaded', async function (info) {
- // Fetch release notes from GitHub API
- const releaseNotes = await fetchReleaseNotes(info.version);
- log.info(JSON.stringify(info))
- // Display dialog to the user with release notes
- dialog.showMessageBox({
- type: 'info',
- title: 'Update Available',
- message: `A new version (${info.version}) is available.\n\nRelease Notes:\n${releaseNotes}\n\nDo you want to install it now?`,
- buttons: ['Quit and Install', 'Install after Exit'],
- defaultId: 1,
- noLink: true
- }).then((result) => {
- if (result.response === 0) {
- // User clicked 'Yes', start the download
- autoUpdater.quitAndInstall();
- }
+ }).then((result) => {
+ if (result.response === 0) {
+ // User clicked 'Yes', start the download
+ autoUpdater.quitAndInstall();
+ }
+ });
});
-});
-function logUpdateStatus(message) {
- console.log(message);
+ function logUpdateStatus(message) {
+ console.log(message);
+ }
}
-
process.stdin.resume();//so the program will not close instantly
async function exitHandler(options, exitCode) {
@@ -171,36 +166,36 @@ async function exitHandler(options, exitCode) {
if (err) {
console.error('Error deleting file:', err);
} else {
- console.log('Deleted file:', file);
+ DEBUG && console.log('Deleted file:', file);
}
});
}
});
});
- // Remove old logs
- const logs = path.join(app.getPath('userData'), 'logs');
- fs.readdir(logs, (err, files) => {
- if (err) {
- console.error('Error reading folder:', err);
- return;
- }
- files.forEach((file) => {
- fs.unlink(path.join(logs, file), (err) => {
- if (err) {
- console.error('Error deleting file:', err);
- } else {
- console.log('Deleted file:', file);
- }
- });
- });
- });
+ // Remove old logs - commented out as logs are rotated
+ // const logs = path.join(app.getPath('userData'), 'logs');
+ // fs.readdir(logs, (err, files) => {
+ // if (err) {
+ // console.error('Error reading folder:', err);
+ // return;
+ // }
+ // files.forEach((file) => {
+ // fs.unlink(path.join(logs, file), (err) => {
+ // if (err) {
+ // console.error('Error deleting file:', err);
+ // } else {
+ // DEBUG && console.log('Deleted file:', file);
+ // }
+ // });
+ // });
+ // });
// Disable debug mode here?
} else {
- console.log('no clean')
+ DEBUG && console.log('no clean')
}
if (exitCode || exitCode === 0) {
- console.log(exitCode);
+ DEBUG && console.log(exitCode);
}
if (options.exit) {
process.exit();
@@ -246,7 +241,7 @@ async function windowStateKeeper(windowName) {
windowState.isMaximized = window.isMaximized();
try {
await settings.set(`windowState.${windowName}`, windowState);
- } catch (error){}
+ } catch {} // do nothing
}
function track(win) {
@@ -293,7 +288,7 @@ async function createWindow() {
// Set icon
mainWindow.setIcon(__dirname + '/img/icon/icon.png');
- // Hide nav bar excpet in ci mode
+ // Hide nav bar except in ci mode
mainWindow.setMenuBarVisibility(!!process.env.CI);
@@ -306,7 +301,7 @@ async function createWindow() {
mainWindow.once('ready-to-show', () => {
mainWindow.show()
})
- console.log("main window created");
+ DEBUG && console.log("main window created");
// Emitted when the window is closed.
if (process.platform !== 'darwin') {
mainWindow.on('closed', () => {
@@ -358,7 +353,7 @@ async function createWorker() {
workerWindow = undefined;
});
if (DEBUG) workerWindow.webContents.openDevTools();
- console.log("worker created");
+ DEBUG && console.log("worker created");
}
// This method will be called when Electron has finished loading
@@ -385,7 +380,7 @@ app.whenReady().then(async () => {
const fileContent = fs.readFileSync(filePath, 'utf8');
const config = JSON.parse(fileContent);
DEBUG = process.env.CI === 'e2e' ? false : config.debug;
- console.log('CI mode' , process.env.CI)
+ DEBUG && console.log('CI mode' , process.env.CI)
}
catch (error) {
// Handle errors, for example, file not found
@@ -418,7 +413,7 @@ app.whenReady().then(async () => {
app.on('open-file', (event, path) => {
files.push(path);
- console.log('file passed to open:', path)
+ DEBUG && console.log('file passed to open:', path)
});
ipcMain.handle('openFiles', async (_event, _method, config) => {
@@ -468,7 +463,7 @@ app.whenReady().then(async () => {
});
workerWindow.webContents.once('render-process-gone', (e, details) => {
- console.log(details);
+ DEBUG && console.log(details);
const dialogOpts = {
type: 'warning',
title: 'Crash report',
@@ -487,6 +482,7 @@ app.whenReady().then(async () => {
autoUpdater.checkForUpdatesAndNotify()
// Allow multiple instances of Chirpity - experimental! This alone doesn't work:
//app.releaseSingleInstanceLock()
+
});
@@ -513,40 +509,66 @@ ipcMain.handle('request-worker-channel', async (_event) =>{
ipcMain.handle('unsaved-records', (_event, data) => {
unsavedRecords = data.newValue; // Update the variable with the new value
- console.log('Unsaved records:', unsavedRecords);
});
-ipcMain.handle('saveFile', (event, arg) => {
+ipcMain.handle('saveFile', async (event, arg) => {
// Show file dialog to select audio file
- let currentFile = arg.currentFile.substr(0, arg.currentFile.lastIndexOf(".")) + ".txt";
- dialog.showSaveDialog({
- filters: [{ name: 'Text Files', extensions: ['txt'] }],
- defaultPath: currentFile
- }).then(file => {
- // Stating whether dialog operation was cancelled or not.
- //console.log(file.canceled);
- if (!file.canceled) {
- const AUDACITY_LABELS = arg.labels;
- let str = "";
- // Format results
- for (let i = 0; i < AUDACITY_LABELS.length; i++) {
- str += AUDACITY_LABELS[i].timestamp + "\t";
- str += " " + AUDACITY_LABELS[i].cname;
- // str += " " + AUDACITY_LABELS[i].sname ;
- str += " " + (parseFloat(AUDACITY_LABELS[i].score) * 100).toFixed(0) + "%\r\n";
+ if (arg.type === 'audacity'){
+ let currentFile = arg.currentFile.substr(0, arg.currentFile.lastIndexOf(".")) + ".txt";
+ dialog.showSaveDialog({
+ filters: [{ name: 'Text Files', extensions: ['txt'] }],
+ defaultPath: currentFile
+ }).then(file => {
+ // Stating whether dialog operation was cancelled or not.
+ //DEBUG && console.log(file.canceled);
+ if (!file.canceled) {
+ const AUDACITY_LABELS = arg.labels;
+ let str = "";
+ // Format results
+ for (let i = 0; i < AUDACITY_LABELS.length; i++) {
+ str += AUDACITY_LABELS[i].timestamp + "\t";
+ str += " " + AUDACITY_LABELS[i].cname;
+ // str += " " + AUDACITY_LABELS[i].sname ;
+ str += " " + (parseFloat(AUDACITY_LABELS[i].score) * 100).toFixed(0) + "%\r\n";
+ }
+ fs.writeFile(file.filePath.toString(),
+ str, function (err) {
+ if (err) throw err;
+ DEBUG && console.log('Saved!');
+ });
}
- fs.writeFile(file.filePath.toString(),
- str, function (err) {
- if (err) throw err;
- console.log('Saved!');
+ }).catch(error => {
+ console.warn(error)
+ });
+ } else {
+ const {file, filename, extension} = arg;
+ dialog.showSaveDialog({
+ title: 'Save File',
+ filters: [{ name: 'Audio files', extensions: [extension] }],
+ defaultPath: filename
+ }).then(saveObj => {
+ // Check if the user cancelled the operation
+ const {canceled, filePath} = saveObj;
+ if (canceled) {
+ DEBUG && console.log('User cancelled the save operation.');
+ fs.rmSync(file);
+ return;
+ }
+
+ // Copy the file from temp directory to the selected save location
+ fs.rename(file, filePath, (err) => {
+ if (err) {
+ console.error('Error saving the file:', err);
+ } else {
+ DEBUG && console.log('File saved successfully to', filePath);
+ }
+ return;
});
- }
- }).catch(error => {
- console.log(error)
- });
- mainWindow.webContents.send('saveFile', { message: 'file saved!' });
+ })
+ }
+
});
@@ -555,9 +577,9 @@ powerSaveBlocker.stop(powerSaveID);
ipcMain.handle('powerSaveControl', (e, on) => {
if (on){
powerSaveID = powerSaveBlocker.start('prevent-app-suspension')
- //console.log(powerSaveBlocker.isStarted(powerSaveID), powerSaveID)
+ //DEBUG && console.log(powerSaveBlocker.isStarted(powerSaveID), powerSaveID)
} else {
powerSaveBlocker.stop(powerSaveID)
- //console.log(powerSaveBlocker.isStarted(powerSaveID), powerSaveID)
+ //DEBUG && console.log(powerSaveBlocker.isStarted(powerSaveID), powerSaveID)
}
})
\ No newline at end of file
diff --git a/package.json b/package.json
index d14c70ae..c73e0c26 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "chirpity",
- "version": "2.0.3",
+ "version": "2.1.0",
"description": "Chirpity Nocmig",
"main": "main.js",
"scripts": {
diff --git a/preload.js b/preload.js
index 529bd111..92c0e3b8 100644
--- a/preload.js
+++ b/preload.js
@@ -39,7 +39,10 @@ ipcRenderer.once('provide-worker-channel', async (event) => {
window.postMessage('provide-worker-channel', '/', event.ports)
})
-
+ipcRenderer.on('error', (event, errorMessage) => {
+ console.error('Uncaught Exception from main process:', errorMessage);
+ alert('Uncaught Exception from main process:', errorMessage)
+ });
contextBridge.exposeInMainWorld('electron', {
requestWorkerChannel: () => ipcRenderer.invoke('request-worker-channel'),