From 8a23516ff5c629946fe57880c0c10a492601aab4 Mon Sep 17 00:00:00 2001 From: mattk70 Date: Sat, 16 Mar 2024 18:40:55 +0000 Subject: [PATCH 1/8] Tidied up CSV/eBird export functions --- js/tracking.js | 2 +- js/worker.js | 105 +++++++++++++++++++------------------------------ 2 files changed, 41 insertions(+), 66 deletions(-) diff --git a/js/tracking.js b/js/tracking.js index bf7bfb49..0130b02b 100644 --- a/js/tracking.js +++ b/js/tracking.js @@ -1,5 +1,5 @@ const DEBUG = false; -const ID_SITE = 2; +const ID_SITE = 3; function trackEvent(uuid, event, action, name, value){ diff --git a/js/worker.js b/js/worker.js index 407ed5e8..6a7d700a 100644 --- a/js/worker.js +++ b/js/worker.js @@ -2512,49 +2512,34 @@ const prepSummaryStatement = (included) => { const latitude = result?.lat || STATE.lat; const longitude = result?.lon || STATE.lon; const place = result?.place || STATE.place; - // Step 1: Remove specified keys - delete modifiedObj.confidence_rank; - delete modifiedObj.filestart; - delete modifiedObj.speciesID; - delete modifiedObj.duration; modifiedObj.score /= 1000; modifiedObj.score = modifiedObj.score.toString().replace(/^2$/, 'confirmed'); // Step 2: Multiply 'end' by 1000 and add 'timestamp' modifiedObj.end = (modifiedObj.end - modifiedObj.position) * 1000 + modifiedObj.timestamp; // Step 3: Convert 'timestamp' and 'end' to a formatted string - //const date = new Date(modifiedObj.timestamp); modifiedObj.timestamp = formatDate(modifiedObj.timestamp) const end = new Date(modifiedObj.end); modifiedObj.end = end.toISOString().slice(0, 19).replace('T', ' '); - // Rename the headers - modifiedObj['File'] = modifiedObj.file - delete modifiedObj.file; - modifiedObj['Detection start'] = modifiedObj.timestamp - delete modifiedObj.timestamp; - modifiedObj['Detection end'] = modifiedObj.end - delete modifiedObj.end; - modifiedObj['Common name'] = modifiedObj.cname - delete modifiedObj.cname; - modifiedObj['Latin name'] = modifiedObj.sname - delete modifiedObj.sname; - modifiedObj['Confidence'] = modifiedObj.score - delete modifiedObj.score; - modifiedObj['Label'] = modifiedObj.label - delete modifiedObj.label; - modifiedObj['Comment'] = modifiedObj.comment - delete modifiedObj.comment; - modifiedObj['Call count'] = modifiedObj.callCount - delete modifiedObj.callCount; - modifiedObj['File offset'] = secondsToHHMMSS(modifiedObj.position) - delete modifiedObj.position; - modifiedObj['Latitude'] = latitude; - modifiedObj['Longitude'] = longitude; - modifiedObj['Place'] = place; - return modifiedObj; + // Create a new object with the right headers + const newObj = {}; + newObj['File'] = modifiedObj.file + newObj['Detection start'] = modifiedObj.timestamp + newObj['Detection end'] = modifiedObj.end + newObj['Common name'] = modifiedObj.cname + newObj['Latin name'] = modifiedObj.sname + newObj['Confidence'] = modifiedObj.score + newObj['Label'] = modifiedObj.label + newObj['Comment'] = modifiedObj.comment + newObj['Call count'] = modifiedObj.callCount + newObj['File offset'] = secondsToHHMMSS(modifiedObj.position) + newObj['Latitude'] = latitude; + newObj['Longitude'] = longitude; + newObj['Place'] = place; + return newObj; } - // Function to format the CSV export + // Function to format the eBird export async function formateBirdValues(obj) { // Create a copy of the original object to avoid modifying it directly const modifiedObj = { ...obj }; @@ -2566,18 +2551,12 @@ const prepSummaryStatement = (included) => { const latitude = result?.lat || STATE.lat; const longitude = result?.lon || STATE.lon; const place = result?.place || STATE.place; - // Step 1: Remove specified keys - const keysToRemove = ['confidence_rank', 'speciesID', 'file', 'fileID', 'label', 'rank', 'end', 'score', 'position']; - modifiedObj.timestamp = modifiedObj.filestart; - delete modifiedObj.filestart; - keysToRemove.forEach(key => delete modifiedObj[key]); - modifiedObj.timestamp = formatDate(modifiedObj.timestamp); + modifiedObj.timestamp = formatDate(modifiedObj.filestart); let [date, time] = modifiedObj.timestamp.split(' '); const [year, month, day] = date.split('-'); date = `${month}/${day}/${year}`; const [hours, minutes] = time.split(':') time = `${hours}:${minutes}`; - delete modifiedObj.timestamp; if (STATE.model === 'chirpity'){ // Regular expression to match the words inside parentheses const regex = /\(([^)]+)\)/; @@ -2587,33 +2566,29 @@ const prepSummaryStatement = (included) => { modifiedObj.cname = name.trim(); // Output: "words words" modifiedObj.comment ??= calltype; } - // Rename the headers - modifiedObj['Common name'] = modifiedObj.cname - delete modifiedObj.cname; const [genus, species] = modifiedObj.sname.split(' '); - delete modifiedObj.sname; - modifiedObj['Genus'] = genus; - modifiedObj['Species'] = species; - modifiedObj['Species Count'] = modifiedObj.callCount || 1; - delete modifiedObj.callCount; - modifiedObj['Species Comments'] = modifiedObj.comment?.replace(/\r?\n/g, ' '); - delete modifiedObj.comment; - modifiedObj['Location Name'] = place; - modifiedObj['Latitude'] = latitude; - modifiedObj['Longitude'] = longitude; - modifiedObj['Date'] = date; - modifiedObj['Start Time'] = time; - modifiedObj['State/Province'] = ''; - modifiedObj['Country'] = ''; - modifiedObj['Protocol'] = 'Stationary'; - modifiedObj['Number of observers'] = '1'; - modifiedObj['Duration'] = Math.ceil(modifiedObj.duration / 60); // todo: get audio duration; - delete modifiedObj.duration; - modifiedObj['All observations reported?'] = 'N'; - modifiedObj['Distance covered'] = ''; - modifiedObj['Area covered'] = ''; - modifiedObj['Submission Comments'] = 'Submission initially generated from Chirpity'; - return modifiedObj; + // Create a new object with the right keys + const newObj = {}; + newObj['Common name'] = modifiedObj.cname; + newObj['Genus'] = genus; + newObj['Species'] = species; + newObj['Species Count'] = modifiedObj.callCount || 1; + newObj['Species Comments'] = modifiedObj.comment?.replace(/\r?\n/g, ' '); + newObj['Location Name'] = place; + newObj['Latitude'] = latitude; + newObj['Longitude'] = longitude; + newObj['Date'] = date; + newObj['Start Time'] = time; + newObj['State/Province'] = ''; + newObj['Country'] = ''; + newObj['Protocol'] = 'Stationary'; + newObj['Number of observers'] = '1'; + newObj['Duration'] = Math.ceil(modifiedObj.duration / 60); + newObj['All observations reported?'] = 'N'; + newObj['Distance covered'] = ''; + newObj['Area covered'] = ''; + newObj['Submission Comments'] = 'Submission initially generated from Chirpity'; + return newObj; } function secondsToHHMMSS(seconds) { From 7dd29b0c0e04b2cd81e6d93d4dc86949191da887 Mon Sep 17 00:00:00 2001 From: mattk70 Date: Sat, 16 Mar 2024 19:31:17 +0000 Subject: [PATCH 2/8] tidied formatting --- js/worker.js | 5133 +++++++++++++++++++++++++------------------------- 1 file changed, 2564 insertions(+), 2569 deletions(-) diff --git a/js/worker.js b/js/worker.js index 6a7d700a..78f9996c 100644 --- a/js/worker.js +++ b/js/worker.js @@ -544,818 +544,643 @@ const prepSummaryStatement = (included) => { JOIN files ON files.id = records.fileID JOIN species ON species.id = records.speciesID WHERE confidence >= ? `; - // If you're using the memory db, you're either anlaysing one, or all of the files - if (['analyse'].includes(STATE.mode) && STATE.filesToAnalyse.length === 1) { - summaryStatement += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; - params.push(...STATE.filesToAnalyse); - } - else if (['archive'].includes(STATE.mode)) { - summaryStatement += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; - params.push(...STATE.filesToAnalyse); - } - else if (useRange) { - summaryStatement += ' AND dateTime BETWEEN ? AND ? '; - params.push(range.start, range.end); - } + // If you're using the memory db, you're either anlaysing one, or all of the files + if (['analyse'].includes(STATE.mode) && STATE.filesToAnalyse.length === 1) { + summaryStatement += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; + params.push(...STATE.filesToAnalyse); + } + else if (['archive'].includes(STATE.mode)) { + summaryStatement += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; + params.push(...STATE.filesToAnalyse); + } + else if (useRange) { + summaryStatement += ' AND dateTime BETWEEN ? AND ? '; + params.push(range.start, range.end); + } + + if (filtersApplied(included)) { + const includedParams = prepParams(included); + summaryStatement += ` AND speciesID IN (${includedParams}) `; + params.push(...included); + } + if (STATE.detect.nocmig){ + summaryStatement += ' AND COALESCE(isDaylight, 0) != 1 '; + } + + if (STATE.locationID) { + summaryStatement += ' AND locationID = ? '; + params.push(STATE.locationID); + } + summaryStatement += ` + ) + SELECT speciesID, cname, sname, COUNT(cname) as count, SUM(callcount) as calls, ROUND(MAX(ranked_records.confidence) / 10.0, 0) as max + FROM ranked_records + WHERE ranked_records.rank <= ${STATE.topRankin}`; + + summaryStatement += ` GROUP BY speciesID ORDER BY cname`; + + return [summaryStatement, params] +} + + +const getTotal = async ({species = undefined, offset = undefined, included = []}) => { + let params = []; + const range = STATE.mode === 'explore' ? STATE.explore.range : undefined; + offset = offset ?? (species !== undefined ? STATE.filteredOffset[species] : STATE.globalOffset); + const useRange = range?.start; + let SQL = ` WITH MaxConfidencePerDateTime AS ( + SELECT confidence, + RANK() OVER (PARTITION BY fileID, dateTime ORDER BY records.confidence DESC) AS rank + FROM records + JOIN files ON records.fileID = files.id + WHERE confidence >= ${STATE.detect.confidence} `; + + if (species) { + params.push(species); + SQL += ' AND speciesID = (SELECT id from species WHERE cname = ?) '; + }// This will overcount as there may be a valid species ranked above it + else if (filtersApplied(included)) SQL += ` AND speciesID IN (${included}) `; + if (useRange) SQL += ` AND dateTime BETWEEN ${range.start} AND ${range.end} `; + if (STATE.detect.nocmig) SQL += ' AND COALESCE(isDaylight, 0) != 1 '; + if (STATE.locationID) SQL += ` AND locationID = ${STATE.locationID}`; + // If you're using the memory db, you're either anlaysing one, or all of the files + if (['analyse'].includes(STATE.mode) && STATE.filesToAnalyse.length === 1) { + SQL += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; + params.push(...STATE.filesToAnalyse); + } + else if (['archive'].includes(STATE.mode)) { + SQL += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; + params.push(...STATE.filesToAnalyse); + } + SQL += ' ) ' + SQL += `SELECT COUNT(confidence) AS total FROM MaxConfidencePerDateTime WHERE rank <= ${STATE.topRankin}`; + + const {total} = await STATE.db.getAsync(SQL, ...params) + UI.postMessage({event: 'total-records', total: total, offset: offset, species: species}) +} + +const prepResultsStatement = (species, noLimit, included, offset, topRankin) => { + const params = [STATE.detect.confidence]; + let resultStatement = ` + WITH ranked_records AS ( + SELECT + records.dateTime, + files.duration, + files.filestart, + fileID, + files.name, + files.locationID, + records.position, + records.speciesID, + species.sname, + species.cname, + records.confidence as score, + records.label, + records.comment, + records.end, + records.callCount, + records.isDaylight, + RANK() OVER (PARTITION BY fileID, dateTime ORDER BY records.confidence DESC) AS rank + FROM records + JOIN species ON records.speciesID = species.id + JOIN files ON records.fileID = files.id + WHERE confidence >= ? + `; - if (filtersApplied(included)) { - const includedParams = prepParams(included); - summaryStatement += ` AND speciesID IN (${includedParams}) `; - params.push(...included); + // If you're using the memory db, you're either anlaysing one, or all of the files + if (['analyse'].includes(STATE.mode) && STATE.filesToAnalyse.length === 1) { + resultStatement += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; + params.push(...STATE.filesToAnalyse); + } + else if (['archive'].includes(STATE.mode)) { + resultStatement += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; + params.push(...STATE.filesToAnalyse); + } + // Prioritise selection ranges + const range = STATE.selection?.start ? STATE.selection : + STATE.mode === 'explore' ? STATE.explore.range : false; + const useRange = range?.start; + if (useRange) { + resultStatement += ` AND dateTime BETWEEN ${range.start} AND ${range.end} `; + } + if (species){ + resultStatement+= ` AND cname = ? `; + params.push(species); + } + else if (filtersApplied(included)) { + resultStatement += ` AND speciesID IN (${prepParams(included)}) `; + params.push(...included); + } + if (STATE.selection) { + resultStatement += ` AND name = ? `; + params.push(FILE_QUEUE[0]) + } + if (STATE.locationID) { + resultStatement += ` AND locationID = ? `; + params.push(STATE.locationID) + } + if (STATE.detect.nocmig){ + resultStatement += ' AND COALESCE(isDaylight, 0) != 1 '; // Backward compatibility for < v0.9. + } + + resultStatement += `) + SELECT + dateTime as timestamp, + score, + duration, + filestart, + name as file, + fileID, + position, + speciesID, + sname, + cname, + score, + label, + comment, + end, + callCount, + rank + FROM + ranked_records + WHERE rank <= ? `; + params.push(topRankin); + + const limitClause = noLimit ? '' : 'LIMIT ? OFFSET ?'; + noLimit || params.push(STATE.limit, offset); + + resultStatement += ` ORDER BY ${STATE.sortOrder}, callCount DESC ${limitClause} `; + + return [resultStatement, params]; +} + + +// Not an arrow function. Async function has access to arguments - so we can pass them to processnextfile +async function onAnalyse({ + filesInScope = [], + start = 0, + end = undefined, + reanalyse = false, + circleClicked = false +}) { + // Now we've asked for a new analysis, clear the aborted flag + aborted = false; STATE.incrementor = 1; + predictionStart = new Date(); + // Set the appropraite selection range if this is a selection analysis + STATE.update({ selection: end ? getSelectionRange(filesInScope[0], start, end) : undefined }); + + DEBUG && console.log(`Worker received message: ${filesInScope}, ${STATE.detect.confidence}, start: ${start}, end: ${end}`); + //Reset GLOBAL variables + index = 0; + AUDACITY = {}; + // canBeRemovedFromCache = []; + batchChunksToSend = {}; + FILE_QUEUE = filesInScope; + + + if (!STATE.selection) { + // Clear records from the memory db + await memoryDB.runAsync('DELETE FROM records; VACUUM'); + //create a copy of files in scope for state, as filesInScope is spliced + STATE.setFiles([...filesInScope]); + } + + let count = 0; + if (DATASET && !STATE.selection && !reanalyse) { + for (let i = FILE_QUEUE.length - 1; i >= 0; i--) { + let file = FILE_QUEUE[i]; + //STATE.db = diskDB; + const result = await diskDB.getAsync('SELECT name FROM files WHERE name = ?', file); + if (result && result.name !== FILE_QUEUE[0]) { + DEBUG && console.log(`Skipping ${file}, already analysed`) + FILE_QUEUE.splice(i, 1) + //filesBeingProcessed.splice(i, 1) + count++ + continue; + } + DEBUG && console.log(`Adding ${file} to the queue.`) } - if (STATE.detect.nocmig){ - summaryStatement += ' AND COALESCE(isDaylight, 0) != 1 '; + } + else { + // check if results for the files are cached + // we only consider it cached if all files have been saved to the disk DB) + // BECAUSE we want to change state.db to disk if they are + let allCached = true; + for (let i = 0; i < FILE_QUEUE.length; i++) { + const file = FILE_QUEUE[i]; + const row = await getSavedFileInfo(file) + if (row) { + await setMetadata({file: file}) + } else { + allCached = false; + break; + } } - - if (STATE.locationID) { - summaryStatement += ' AND locationID = ? '; - params.push(STATE.locationID); + const retrieveFromDatabase = ((allCached && !reanalyse && !STATE.selection) || circleClicked); + if (retrieveFromDatabase) { + filesBeingProcessed = []; + if (circleClicked) { + // handle circle here + await getResults({ topRankin: 5 }); + } else { + await onChangeMode('archive'); + FILE_QUEUE.forEach(file => UI.postMessage({ event: 'update-audio-duration', value: metadata[file].duration })); + // Wierdness with promise all - list worker called 2x and no results returned + //await Promise.all([getResults(), getSummary()] ); + await getResults(); + await getSummary(); + } + return; } - summaryStatement += ` - ) - SELECT speciesID, cname, sname, COUNT(cname) as count, SUM(callcount) as calls, ROUND(MAX(ranked_records.confidence) / 10.0, 0) as max - FROM ranked_records - WHERE ranked_records.rank <= ${STATE.topRankin}`; - summaryStatement += ` GROUP BY speciesID ORDER BY cname`; - - return [summaryStatement, params] } + DEBUG && console.log("FILE_QUEUE has", FILE_QUEUE.length, 'files', count, 'files ignored') + STATE.selection || onChangeMode('analyse'); + filesBeingProcessed = [...FILE_QUEUE]; + + // for (let i = 0; i < filesBeingProcessed.length; i++) { + for (let i = 0; i < NUM_WORKERS; i++) { + processNextFile({ start: start, end: end, worker: i }); + } +} - const getTotal = async ({species = undefined, offset = undefined, included = []}) => { - let params = []; - const range = STATE.mode === 'explore' ? STATE.explore.range : undefined; - offset = offset ?? (species !== undefined ? STATE.filteredOffset[species] : STATE.globalOffset); - const useRange = range?.start; - let SQL = ` WITH MaxConfidencePerDateTime AS ( - SELECT confidence, - RANK() OVER (PARTITION BY fileID, dateTime ORDER BY records.confidence DESC) AS rank - FROM records - JOIN files ON records.fileID = files.id - WHERE confidence >= ${STATE.detect.confidence} `; - - if (species) { - params.push(species); - SQL += ' AND speciesID = (SELECT id from species WHERE cname = ?) '; - }// This will overcount as there may be a valid species ranked above it - else if (filtersApplied(included)) SQL += ` AND speciesID IN (${included}) `; - if (useRange) SQL += ` AND dateTime BETWEEN ${range.start} AND ${range.end} `; - if (STATE.detect.nocmig) SQL += ' AND COALESCE(isDaylight, 0) != 1 '; - if (STATE.locationID) SQL += ` AND locationID = ${STATE.locationID}`; - // If you're using the memory db, you're either anlaysing one, or all of the files - if (['analyse'].includes(STATE.mode) && STATE.filesToAnalyse.length === 1) { - SQL += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; - params.push(...STATE.filesToAnalyse); - } - else if (['archive'].includes(STATE.mode)) { - SQL += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; - params.push(...STATE.filesToAnalyse); - } - SQL += ' ) ' - SQL += `SELECT COUNT(confidence) AS total FROM MaxConfidencePerDateTime WHERE rank <= ${STATE.topRankin}`; - - const {total} = await STATE.db.getAsync(SQL, ...params) - UI.postMessage({event: 'total-records', total: total, offset: offset, species: species}) - } - - const prepResultsStatement = (species, noLimit, included, offset, topRankin) => { - const params = [STATE.detect.confidence]; - let resultStatement = ` - WITH ranked_records AS ( - SELECT - records.dateTime, - files.duration, - files.filestart, - fileID, - files.name, - files.locationID, - records.position, - records.speciesID, - species.sname, - species.cname, - records.confidence as score, - records.label, - records.comment, - records.end, - records.callCount, - records.isDaylight, - RANK() OVER (PARTITION BY fileID, dateTime ORDER BY records.confidence DESC) AS rank - FROM records - JOIN species ON records.speciesID = species.id - JOIN files ON records.fileID = files.id - WHERE confidence >= ? - `; - - // If you're using the memory db, you're either anlaysing one, or all of the files - if (['analyse'].includes(STATE.mode) && STATE.filesToAnalyse.length === 1) { - resultStatement += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; - params.push(...STATE.filesToAnalyse); - } - else if (['archive'].includes(STATE.mode)) { - resultStatement += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; - params.push(...STATE.filesToAnalyse); - } - // Prioritise selection ranges - const range = STATE.selection?.start ? STATE.selection : - STATE.mode === 'explore' ? STATE.explore.range : false; - const useRange = range?.start; - if (useRange) { - resultStatement += ` AND dateTime BETWEEN ${range.start} AND ${range.end} `; - } - if (species){ - resultStatement+= ` AND cname = ? `; - params.push(species); - } - else if (filtersApplied(included)) { - resultStatement += ` AND speciesID IN (${prepParams(included)}) `; - params.push(...included); - } - if (STATE.selection) { - resultStatement += ` AND name = ? `; - params.push(FILE_QUEUE[0]) - } - if (STATE.locationID) { - resultStatement += ` AND locationID = ? `; - params.push(STATE.locationID) - } - if (STATE.detect.nocmig){ - resultStatement += ' AND COALESCE(isDaylight, 0) != 1 '; // Backward compatibility for < v0.9. - } - - resultStatement += `) - SELECT - dateTime as timestamp, - score, - duration, - filestart, - name as file, - fileID, - position, - speciesID, - sname, - cname, - score, - label, - comment, - end, - callCount, - rank - FROM - ranked_records - WHERE rank <= ? `; - params.push(topRankin); - - const limitClause = noLimit ? '' : 'LIMIT ? OFFSET ?'; - noLimit || params.push(STATE.limit, offset); - - resultStatement += ` ORDER BY ${STATE.sortOrder}, callCount DESC ${limitClause} `; - - return [resultStatement, params]; - } - - - // Not an arrow function. Async function has access to arguments - so we can pass them to processnextfile - async function onAnalyse({ - filesInScope = [], - start = 0, - end = undefined, - reanalyse = false, - circleClicked = false - }) { - // Now we've asked for a new analysis, clear the aborted flag - aborted = false; STATE.incrementor = 1; - predictionStart = new Date(); - // Set the appropraite selection range if this is a selection analysis - STATE.update({ selection: end ? getSelectionRange(filesInScope[0], start, end) : undefined }); - - DEBUG && console.log(`Worker received message: ${filesInScope}, ${STATE.detect.confidence}, start: ${start}, end: ${end}`); - //Reset GLOBAL variables - index = 0; - AUDACITY = {}; - // canBeRemovedFromCache = []; - batchChunksToSend = {}; - FILE_QUEUE = filesInScope; - - - if (!STATE.selection) { - // Clear records from the memory db - await memoryDB.runAsync('DELETE FROM records; VACUUM'); - //create a copy of files in scope for state, as filesInScope is spliced - STATE.setFiles([...filesInScope]); - } - - let count = 0; - if (DATASET && !STATE.selection && !reanalyse) { - for (let i = FILE_QUEUE.length - 1; i >= 0; i--) { - let file = FILE_QUEUE[i]; - //STATE.db = diskDB; - const result = await diskDB.getAsync('SELECT name FROM files WHERE name = ?', file); - if (result && result.name !== FILE_QUEUE[0]) { - DEBUG && console.log(`Skipping ${file}, already analysed`) - FILE_QUEUE.splice(i, 1) - //filesBeingProcessed.splice(i, 1) - count++ - continue; - } - DEBUG && console.log(`Adding ${file} to the queue.`) - } - } - else { - // check if results for the files are cached - // we only consider it cached if all files have been saved to the disk DB) - // BECAUSE we want to change state.db to disk if they are - let allCached = true; - for (let i = 0; i < FILE_QUEUE.length; i++) { - const file = FILE_QUEUE[i]; - const row = await getSavedFileInfo(file) - if (row) { - await setMetadata({file: file}) - } else { - allCached = false; - break; - } - } - const retrieveFromDatabase = ((allCached && !reanalyse && !STATE.selection) || circleClicked); - if (retrieveFromDatabase) { - filesBeingProcessed = []; - if (circleClicked) { - // handle circle here - await getResults({ topRankin: 5 }); - } else { - await onChangeMode('archive'); - FILE_QUEUE.forEach(file => UI.postMessage({ event: 'update-audio-duration', value: metadata[file].duration })); - // Wierdness with promise all - list worker called 2x and no results returned - //await Promise.all([getResults(), getSummary()] ); - await getResults(); - await getSummary(); - } - return; - } - - } - DEBUG && console.log("FILE_QUEUE has", FILE_QUEUE.length, 'files', count, 'files ignored') - STATE.selection || onChangeMode('analyse'); - - filesBeingProcessed = [...FILE_QUEUE]; - - // for (let i = 0; i < filesBeingProcessed.length; i++) { - for (let i = 0; i < NUM_WORKERS; i++) { - processNextFile({ start: start, end: end, worker: i }); - } - } - - function onAbort({ - model = STATE.model, - list = 'nocturnal', - }) { - aborted = true; - FILE_QUEUE = []; - index = 0; - DEBUG && console.log("abort received") - if (filesBeingProcessed.length) { - //restart the worker - terminateWorkers(); - spawnPredictWorkers(model, list, BATCH_SIZE, NUM_WORKERS) - } - filesBeingProcessed = []; - predictionsReceived = {}; - predictionsRequested = {}; - } - - const getDuration = async (src) => { - let audio; - return new Promise(function (resolve) { - audio = new Audio(); - audio.src = src; - audio.addEventListener("loadedmetadata", function () { - const duration = audio.duration; - audio = undefined; - // Tidy up - cloning removes event listeners - const old_element = document.getElementById("audio"); - const new_element = old_element.cloneNode(true); - old_element.parentNode.replaceChild(new_element, old_element); - - resolve(duration); - }); - }); - } +function onAbort({ + model = STATE.model, + list = 'nocturnal', +}) { + aborted = true; + FILE_QUEUE = []; + index = 0; + DEBUG && console.log("abort received") + if (filesBeingProcessed.length) { + //restart the worker + terminateWorkers(); + spawnPredictWorkers(model, list, BATCH_SIZE, NUM_WORKERS) + } + filesBeingProcessed = []; + predictionsReceived = {}; + predictionsRequested = {}; +} +const getDuration = async (src) => { + let audio; + return new Promise(function (resolve) { + audio = new Audio(); + audio.src = src; + audio.addEventListener("loadedmetadata", function () { + const duration = audio.duration; + audio = undefined; + // Tidy up - cloning removes event listeners + const old_element = document.getElementById("audio"); + const new_element = old_element.cloneNode(true); + old_element.parentNode.replaceChild(new_element, old_element); - /** - * getWorkingFile's purpose is to locate a file and set its metadata. - * @param file: full path to source file - * @returns {Promise} - */ - async function getWorkingFile(file) { - - if (metadata[file]?.isComplete && metadata[file]?.proxy) return metadata[file].proxy; - // find the file - const source_file = fs.existsSync(file) ? file : await locateFile(file); - if (!source_file) return false; - let proxy = source_file; - - if (!metadata.file?.isComplete) { - await setMetadata({ file: file, proxy: proxy, source_file: source_file }); - } - return proxy; - } - - /** - * Function to return path to file searching for new extensions if original file has been compressed. - * @param file - * @returns {Promise<*>} - */ - async function locateFile(file) { - // Ordered from the highest likely quality to lowest - const supported_files = ['.wav', '.flac', '.opus', '.m4a', '.mp3', '.mpga', '.ogg', '.aac', '.mpeg', '.mp4']; - const dir = p.parse(file).dir, name = p.parse(file).name; - // Check folder exists before trying to traverse it. If not, return empty list - let [, folderInfo] = fs.existsSync(dir) ? - await dirInfo({ folder: dir, recursive: false }) : ['', []]; - let filesInFolder = []; - folderInfo.forEach(item => { - filesInFolder.push(item[0]) - }) - let supportedVariants = [] - supported_files.forEach(ext => { - supportedVariants.push(p.join(dir, name + ext)) - }) - const matchingFileExt = supportedVariants.find(variant => { - const matching = (file) => variant.toLowerCase() === file.toLowerCase(); - return filesInFolder.some(matching) - }) - if (!matchingFileExt) { - notifyMissingFile(file) - return false; - } - return matchingFileExt; - } - - async function notifyMissingFile(file) { - let missingFile; - // Look for the file in te Archive - const row = await diskDB.getAsync('SELECT * FROM FILES WHERE name = ?', file); - if (row?.id) missingFile = file - UI.postMessage({ - event: 'generate-alert', - message: `Unable to locate source file with any supported file extension: ${file}`, - file: missingFile - }) - } + resolve(duration); + }); + }); +} - async function loadAudioFile({ - file = '', - start = 0, - end = 20, - position = 0, - region = false, - preserveResults = false, - play = false, - queued = false, - goToRegion = true - }) { - - const found = metadata[file]?.proxy || await getWorkingFile(file); - if (found) { - await fetchAudioBuffer({ file, start, end }) - .then((buffer) => { - let audioArray = buffer.getChannelData(0); - UI.postMessage({ - event: 'worker-loaded-audio', - location: metadata[file].locationID, - start: metadata[file].fileStart, - sourceDuration: metadata[file].duration, - bufferBegin: start, - file: file, - position: position, - contents: audioArray, - fileRegion: region, - preserveResults: preserveResults, - play: play, - queued: queued, - goToRegion - }, [audioArray.buffer]); - }) - .catch( (error) => { - console.log(error); - }) - let week; - if (STATE.list === 'location'){ - week = STATE.useWeek ? new Date(metadata[file].fileStart).getWeekNumber() : -1 - // Send the week number of the surrent file - UI.postMessage({event: 'current-file-week', week: week}) - } else { UI.postMessage({event: 'current-file-week', week: undefined}) } - } - } - - - function addDays(date, days) { - let result = new Date(date); - result.setDate(result.getDate() + days); - return result; - } - - + +/** +* getWorkingFile's purpose is to locate a file and set its metadata. +* @param file: full path to source file +* @returns {Promise} +*/ +async function getWorkingFile(file) { + + if (metadata[file]?.isComplete && metadata[file]?.proxy) return metadata[file].proxy; + // find the file + const source_file = fs.existsSync(file) ? file : await locateFile(file); + if (!source_file) return false; + let proxy = source_file; + + if (!metadata.file?.isComplete) { + await setMetadata({ file: file, proxy: proxy, source_file: source_file }); + } + return proxy; +} + +/** +* Function to return path to file searching for new extensions if original file has been compressed. +* @param file +* @returns {Promise<*>} +*/ +async function locateFile(file) { + // Ordered from the highest likely quality to lowest + const supported_files = ['.wav', '.flac', '.opus', '.m4a', '.mp3', '.mpga', '.ogg', '.aac', '.mpeg', '.mp4']; + const dir = p.parse(file).dir, name = p.parse(file).name; + // Check folder exists before trying to traverse it. If not, return empty list + let [, folderInfo] = fs.existsSync(dir) ? + await dirInfo({ folder: dir, recursive: false }) : ['', []]; + let filesInFolder = []; + folderInfo.forEach(item => { + filesInFolder.push(item[0]) + }) + let supportedVariants = [] + supported_files.forEach(ext => { + supportedVariants.push(p.join(dir, name + ext)) + }) + const matchingFileExt = supportedVariants.find(variant => { + const matching = (file) => variant.toLowerCase() === file.toLowerCase(); + return filesInFolder.some(matching) + }) + if (!matchingFileExt) { + notifyMissingFile(file) + return false; + } + return matchingFileExt; +} + +async function notifyMissingFile(file) { + let missingFile; + // Look for the file in te Archive + const row = await diskDB.getAsync('SELECT * FROM FILES WHERE name = ?', file); + if (row?.id) missingFile = file + UI.postMessage({ + event: 'generate-alert', + message: `Unable to locate source file with any supported file extension: ${file}`, + file: missingFile + }) +} + +async function loadAudioFile({ + file = '', + start = 0, + end = 20, + position = 0, + region = false, + preserveResults = false, + play = false, + queued = false, + goToRegion = true +}) { + + const found = metadata[file]?.proxy || await getWorkingFile(file); + if (found) { + await fetchAudioBuffer({ file, start, end }) + .then((buffer) => { + let audioArray = buffer.getChannelData(0); + UI.postMessage({ + event: 'worker-loaded-audio', + location: metadata[file].locationID, + start: metadata[file].fileStart, + sourceDuration: metadata[file].duration, + bufferBegin: start, + file: file, + position: position, + contents: audioArray, + fileRegion: region, + preserveResults: preserveResults, + play: play, + queued: queued, + goToRegion + }, [audioArray.buffer]); + }) + .catch( (error) => { + console.log(error); + }) + let week; + if (STATE.list === 'location'){ + week = STATE.useWeek ? new Date(metadata[file].fileStart).getWeekNumber() : -1 + // Send the week number of the surrent file + UI.postMessage({event: 'current-file-week', week: week}) + } else { UI.postMessage({event: 'current-file-week', week: undefined}) } + } +} + + +function addDays(date, days) { + let result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + + + +/** +* Called by getWorkingFile, setCustomLocation +* Assigns file metadata to a metadata cache object. file is the key, and is the source file +* proxy is required if the source file is not a wav to populate the headers +* @param file: the file name passed to the worker +* @param proxy: the wav file to use for predictions +* @param source_file: the file that exists ( will be different after compression) +* @returns {Promise} +*/ +const setMetadata = async ({ file, proxy = file, source_file = file }) => { + metadata[file] ??= { proxy: proxy }; + metadata[file].proxy ??= proxy; + // CHeck the database first, so we honour any manual updates. + const savedMeta = await getSavedFileInfo(file); + // If we have stored imfo about the file, set the saved flag; + metadata[file].isSaved = !!savedMeta; + // Latitude only provided when updating location + // const latitude = savedMeta?.lat || STATE.lat; + // const longitude = savedMeta?.lon || STATE.lon; + // const row = await STATE.db.getAsync('SELECT id FROM locations WHERE lat = ? and lon = ?', latitude, longitude); + + // using the nullish coalescing operator + metadata[file].locationID ??= savedMeta?.locationID; + + metadata[file].duration ??= savedMeta?.duration || await getDuration(file); + + return new Promise((resolve) => { + if (metadata[file].isComplete) { + resolve(metadata[file]) + } else { + let fileStart, fileEnd; - /** - * Called by getWorkingFile, setCustomLocation - * Assigns file metadata to a metadata cache object. file is the key, and is the source file - * proxy is required if the source file is not a wav to populate the headers - * @param file: the file name passed to the worker - * @param proxy: the wav file to use for predictions - * @param source_file: the file that exists ( will be different after compression) - * @returns {Promise} - */ - const setMetadata = async ({ file, proxy = file, source_file = file }) => { - metadata[file] ??= { proxy: proxy }; - metadata[file].proxy ??= proxy; - // CHeck the database first, so we honour any manual updates. - const savedMeta = await getSavedFileInfo(file); - // If we have stored imfo about the file, set the saved flag; - metadata[file].isSaved = !!savedMeta; - // Latitude only provided when updating location - // const latitude = savedMeta?.lat || STATE.lat; - // const longitude = savedMeta?.lon || STATE.lon; - // const row = await STATE.db.getAsync('SELECT id FROM locations WHERE lat = ? and lon = ?', latitude, longitude); - - // using the nullish coalescing operator - metadata[file].locationID ??= savedMeta?.locationID; - - metadata[file].duration ??= savedMeta?.duration || await getDuration(file); - - return new Promise((resolve) => { - if (metadata[file].isComplete) { - resolve(metadata[file]) - } else { - let fileStart, fileEnd; - - if (savedMeta?.fileStart) { - fileStart = new Date(savedMeta.fileStart); - fileEnd = new Date(fileStart.getTime() + (metadata[file].duration * 1000)); - } else { - metadata[file].stat = fs.statSync(source_file); - fileEnd = new Date(metadata[file].stat.mtime); - fileStart = new Date(metadata[file].stat.mtime - (metadata[file].duration * 1000)); - } - - // split the duration of this file across any dates it spans - metadata[file].dateDuration = {}; - const key = new Date(fileStart); - key.setHours(0, 0, 0, 0); - const keyCopy = addDays(key, 0).getTime(); - if (fileStart.getDate() === fileEnd.getDate()) { - metadata[file].dateDuration[keyCopy] = metadata[file].duration; - } else { - const key2 = addDays(key, 1); - - const key2Copy = addDays(key2, 0).getTime(); - metadata[file].dateDuration[keyCopy] = (key2Copy - fileStart) / 1000; - metadata[file].dateDuration[key2Copy] = metadata[file].duration - metadata[file].dateDuration[keyCopy]; - } - // Now we have completed the date comparison above, we convert fileStart to millis - fileStart = fileStart.getTime(); - metadata[file].fileStart = fileStart; - return resolve(metadata[file]); - } - }) + if (savedMeta?.fileStart) { + fileStart = new Date(savedMeta.fileStart); + fileEnd = new Date(fileStart.getTime() + (metadata[file].duration * 1000)); + } else { + metadata[file].stat = fs.statSync(source_file); + fileEnd = new Date(metadata[file].stat.mtime); + fileStart = new Date(metadata[file].stat.mtime - (metadata[file].duration * 1000)); } - async function setupCtx(audio, rate, destination) { - rate ??= sampleRate; - // Deal with detached arraybuffer issue - const useFilters = (STATE.filters.sendToModel && STATE.filters.active) || destination === 'UI'; - return audioCtx.decodeAudioData(audio.buffer) - .then( audioBufferChunk => { - const audioCtxSource = audioCtx.createBufferSource(); - audioCtxSource.buffer = audioBufferChunk; - const duration = audioCtxSource.buffer.duration; - const buffer = audioCtxSource.buffer; - - const offlineCtx = new OfflineAudioContext(1, rate * duration, rate); - const offlineSource = offlineCtx.createBufferSource(); - offlineSource.buffer = buffer; - let previousFilter = undefined; - if (useFilters){ - if (STATE.filters.active) { - if (STATE.filters.highPassFrequency) { - // Create a highpass filter to cut low-frequency noise - const highpassFilter = offlineCtx.createBiquadFilter(); - highpassFilter.type = "highpass"; // Standard second-order resonant highpass filter with 12dB/octave rolloff. Frequencies below the cutoff are attenuated; frequencies above it pass through. - highpassFilter.frequency.value = STATE.filters.highPassFrequency; //frequency || 0; // This sets the cutoff frequency. 0 is off. - highpassFilter.Q.value = 0; // Indicates how peaked the frequency is around the cutoff. The greater the value, the greater the peak. - offlineSource.connect(highpassFilter); - previousFilter = highpassFilter; - } - if (STATE.filters.lowShelfFrequency && STATE.filters.lowShelfAttenuation) { - // Create a lowshelf filter to attenuate low-frequency noise - const lowshelfFilter = offlineCtx.createBiquadFilter(); - lowshelfFilter.type = 'lowshelf'; - lowshelfFilter.frequency.value = STATE.filters.lowShelfFrequency; // This sets the cutoff frequency of the lowshelf filter to 1000 Hz - lowshelfFilter.gain.value = STATE.filters.lowShelfAttenuation; // This sets the boost or attenuation in decibels (dB) - previousFilter ? previousFilter.connect(lowshelfFilter) : offlineSource.connect(lowshelfFilter); - previousFilter = lowshelfFilter; - } - } - } - if (STATE.audio.gain){ - var gainNode = offlineCtx.createGain(); - gainNode.gain.value = Math.pow(10, STATE.audio.gain / 20); - previousFilter ? previousFilter.connect(gainNode) : offlineSource.connect(gainNode); - gainNode.connect(offlineCtx.destination); - } else { - previousFilter ? previousFilter.connect(offlineCtx.destination) : offlineSource.connect(offlineCtx.destination); - } - offlineSource.start(); - return offlineCtx; - } ) - .catch( (error) => console.log(error)); - - - // // Create a compressor node - // const compressor = new DynamicsCompressorNode(offlineCtx, { - // threshold: -30, - // knee: 6, - // ratio: 6, - // attack: 0, - // release: 0, - // }); - // previousFilter = offlineSource.connect(compressor) ; - - - // previousFilter ? previousFilter.connect(offlineCtx.destination) : offlineSource.connect(offlineCtx.destination); - - - // // Create a highshelf filter to boost or attenuate high-frequency content - // const highshelfFilter = offlineCtx.createBiquadFilter(); - // highshelfFilter.type = 'highshelf'; - // highshelfFilter.frequency.value = STATE.highPassFrequency || 0; // This sets the cutoff frequency of the highshelf filter to 3000 Hz - // highshelfFilter.gain.value = 0; // This sets the boost or attenuation in decibels (dB) - - - // Add audio normalizer as an Audio Worklet - // if (!normalizerNode){ - // await offlineCtx.audioWorklet.addModule('js/audio_normalizer_processor.js'); - // normalizerNode = new AudioWorkletNode(offlineCtx, 'audio-normalizer-processor'); - // } - // // Connect the nodes - // previousFilter ? previousFilter.connect(normalizerNode) : offlineSource.connect(normalizerNode); - // previousFilter = normalizerNode; - - // // Create a gain node to adjust the audio level + // split the duration of this file across any dates it spans + metadata[file].dateDuration = {}; + const key = new Date(fileStart); + key.setHours(0, 0, 0, 0); + const keyCopy = addDays(key, 0).getTime(); + if (fileStart.getDate() === fileEnd.getDate()) { + metadata[file].dateDuration[keyCopy] = metadata[file].duration; + } else { + const key2 = addDays(key, 1); - }; - - /** - * - * @param file - * @param start - * @param end - * @returns {Promise} - */ + const key2Copy = addDays(key2, 0).getTime(); + metadata[file].dateDuration[keyCopy] = (key2Copy - fileStart) / 1000; + metadata[file].dateDuration[key2Copy] = metadata[file].duration - metadata[file].dateDuration[keyCopy]; + } + // Now we have completed the date comparison above, we convert fileStart to millis + fileStart = fileStart.getTime(); + metadata[file].fileStart = fileStart; + return resolve(metadata[file]); + } + }) +} - const getWavePredictBuffers = async ({ - file = '', start = 0, end = undefined - }) => { - let chunkLength = STATE.model === 'birdnet' ? 144_000 : 72_000; - // Ensure max and min are within range - start = Math.max(0, start); - end = Math.min(metadata[file].duration, end); - if (start > metadata[file].duration) { - return +async function setupCtx(audio, rate, destination) { + rate ??= sampleRate; + // Deal with detached arraybuffer issue + const useFilters = (STATE.filters.sendToModel && STATE.filters.active) || destination === 'UI'; + return audioCtx.decodeAudioData(audio.buffer) + .then( audioBufferChunk => { + const audioCtxSource = audioCtx.createBufferSource(); + audioCtxSource.buffer = audioBufferChunk; + const duration = audioCtxSource.buffer.duration; + const buffer = audioCtxSource.buffer; + + const offlineCtx = new OfflineAudioContext(1, rate * duration, rate); + const offlineSource = offlineCtx.createBufferSource(); + offlineSource.buffer = buffer; + let previousFilter = undefined; + if (useFilters){ + if (STATE.filters.active) { + if (STATE.filters.highPassFrequency) { + // Create a highpass filter to cut low-frequency noise + const highpassFilter = offlineCtx.createBiquadFilter(); + highpassFilter.type = "highpass"; // Standard second-order resonant highpass filter with 12dB/octave rolloff. Frequencies below the cutoff are attenuated; frequencies above it pass through. + highpassFilter.frequency.value = STATE.filters.highPassFrequency; //frequency || 0; // This sets the cutoff frequency. 0 is off. + highpassFilter.Q.value = 0; // Indicates how peaked the frequency is around the cutoff. The greater the value, the greater the peak. + offlineSource.connect(highpassFilter); + previousFilter = highpassFilter; } - let meta = {}; - batchChunksToSend[file] = Math.ceil((end - start) / (BATCH_SIZE * WINDOW_SIZE)); - predictionsReceived[file] = 0; - predictionsRequested[file] = 0; - let readStream; - - // extract the header - const headerStream = fs.createReadStream(file, {start: 0, end: 4096}); - headerStream.on('data', function (chunk) { - let wav = new wavefileReader.WaveFileReader(); - try { - wav.fromBuffer(chunk); - } catch (e) { - UI.postMessage({event: 'generate-alert', message: `Cannot parse ${file}, it has an invalid wav header.`}); - headerStream.close(); - updateFilesBeingProcessed(file); - return; - } - let headerEnd; - wav.signature.subChunks.forEach(el => { - if (el['chunkId'] === 'data') { - headerEnd = el.chunkData.start; - } - }); - meta.header = chunk.subarray(0, headerEnd); - const byteRate = wav.fmt.byteRate; - const sample_rate = wav.fmt.sampleRate; - meta.byteStart = Math.round((start * byteRate) / sample_rate) * sample_rate; - meta.byteEnd = Math.round((end * byteRate) / sample_rate) * sample_rate; - meta.highWaterMark = byteRate * BATCH_SIZE * WINDOW_SIZE; - headerStream.destroy(); - DEBUG && console.log('Header extracted for ', file) - - - readStream = fs.createReadStream(file, { - start: meta.byteStart, end: meta.byteEnd, highWaterMark: meta.highWaterMark - }); - - - let chunkStart = start * sampleRate; - // Changed on.('data') handler because of: https://stackoverflow.com/questions/32978094/nodejs-streams-and-premature-end - readStream.on('readable', async () => { - const chunk = readStream.read(); - if (chunk === null) return; - // The stream seems to read one more byte than the end - else if (chunk.byteLength <= 1 ) { - predictionsReceived[file]++; - return - } - if (aborted) { - readStream.destroy() - return - } - - try { - let audio = Buffer.concat([meta.header, chunk]); - - const offlineCtx = await setupCtx(audio, undefined, 'model'); - let worker; - if (offlineCtx) { - offlineCtx.startRendering().then((resampled) => { - const myArray = resampled.getChannelData(0); - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - feedChunksToModel(myArray, chunkStart, file, end, worker); - chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; - }).catch((error) => { - console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); - const fileIndex = filesBeingProcessed.indexOf(file); - if (fileIndex !== -1) { - canBeRemovedFromCache.push(filesBeingProcessed.splice(fileIndex, 1)) - } - // Note: The promise should reject when startRendering is called a second time on an OfflineAudioContext - }); - } else { - console.log('Short chunk', chunk.length, 'padding') - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - - // Create array with 0's (short segment of silence that will trigger the finalChunk flag - const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); - feedChunksToModel(myArray, chunkStart, file, end); - //readStream.resume(); - } - } catch (error) { - console.warn(file, error) - //trackError(error.message, 'getWavePredictBuffers', STATE.batchSize); - //UI.postMessage({event: 'generate-alert', message: "Chirpity is struggling to handle the high number of prediction requests. You should increasse the batch size in settings to compensate for this, or risk skipping audio sections. Press Esc to abort."}) - } - }) - // readStream.on('end', function () { - // //readStream.close(); - // DEBUG && console.log('All chunks sent for ', file) - // }) - readStream.on('error', err => { - console.log(`readstream error: ${err}, start: ${start}, , end: ${end}, duration: ${metadata[file].duration}`); - err.code === 'ENOENT' && notifyMissingFile(file); - }) - }) - } - - const getPredictBuffers = async ({ - file = '', start = 0, end = undefined - }) => { - let chunkLength = STATE.model === 'birdnet' ? 144_000 : 72_000; - // Ensure max and min are within range - start = Math.max(0, start); - end = Math.min(metadata[file].duration, end); - if (start > metadata[file].duration) { - return + if (STATE.filters.lowShelfFrequency && STATE.filters.lowShelfAttenuation) { + // Create a lowshelf filter to attenuate low-frequency noise + const lowshelfFilter = offlineCtx.createBiquadFilter(); + lowshelfFilter.type = 'lowshelf'; + lowshelfFilter.frequency.value = STATE.filters.lowShelfFrequency; // This sets the cutoff frequency of the lowshelf filter to 1000 Hz + lowshelfFilter.gain.value = STATE.filters.lowShelfAttenuation; // This sets the boost or attenuation in decibels (dB) + previousFilter ? previousFilter.connect(lowshelfFilter) : offlineSource.connect(lowshelfFilter); + previousFilter = lowshelfFilter; } + } + } + if (STATE.audio.gain){ + var gainNode = offlineCtx.createGain(); + gainNode.gain.value = Math.pow(10, STATE.audio.gain / 20); + previousFilter ? previousFilter.connect(gainNode) : offlineSource.connect(gainNode); + gainNode.connect(offlineCtx.destination); + } else { + previousFilter ? previousFilter.connect(offlineCtx.destination) : offlineSource.connect(offlineCtx.destination); + } + offlineSource.start(); + return offlineCtx; + } ) + .catch( (error) => console.log(error)); - batchChunksToSend[file] = Math.ceil((end - start) / (BATCH_SIZE * WINDOW_SIZE)); - predictionsReceived[file] = 0; - predictionsRequested[file] = 0; - let concatenatedBuffer = Buffer.alloc(0); - const highWaterMark = 2 * sampleRate * BATCH_SIZE * WINDOW_SIZE; - const STREAM = new PassThrough({ highWaterMark: highWaterMark, end: true}); - - let chunkStart = start * sampleRate; - return new Promise((resolve, reject) => { - const command = ffmpeg(file) - .seekInput(start) - .duration(end - start) - .format('wav') - //.outputOptions('-acodec pcm_s16le') - .audioChannels(1) // Set to mono - .audioFrequency(sampleRate) // Set sample rate - .output(STREAM) - command.on('error', error => { - updateFilesBeingProcessed(file) - if (error.message.includes('SIGKILL')) DEBUG && console.log('FFMPEG process shut down') - else { - error.message = error.message + '|' + error.stack; - } - reject(console.warn('Error in ffmpeg extracting audio segment:', error.message)); - }); - command.on('start', function (commandLine) { - DEBUG && console.log('FFmpeg command: ' + commandLine); - }) - command.on('end', () => { - // End the stream to signify completion - //STREAM.end(); - }); - - STREAM.on('readable', async () => { - let chunk = STREAM.read(); - - if (aborted) { - command.kill() - STREAM.destroy() - return - } - if (chunk === null || chunk.byteLength <= 1) { - // deal with part-full buffers - if (concatenatedBuffer.length){ - const audio = Buffer.concat([WAV_HEADER, concatenatedBuffer]); - await sendBuffer(audio, chunkStart, chunkLength, end, file) - } - DEBUG && console.log('All chunks sent for ', file); - //command.kill(); - resolve('finished') - } - else { - const bufferList = [concatenatedBuffer, chunk].filter(buf => buf.length > 0); - try { - concatenatedBuffer = Buffer.concat(bufferList); - } catch (error) { - console.warn(error) - //trackError(error.message, 'getPredictBuffers', STATE.batchSize); - //UI.postMessage({event: 'generate-alert', message: "Chirpity is struggling to handle the high number of prediction requests. You should increasse the batch size in settings to compensate for this, or risk skipping audio sections. Press Esc to abort."}) - - } - - // if we have a full buffer - if (concatenatedBuffer.length >= highWaterMark) { - chunk = concatenatedBuffer.subarray(0, highWaterMark); - concatenatedBuffer = concatenatedBuffer.subarray(highWaterMark); - const audio = Buffer.concat([WAV_HEADER, chunk]) - const offlineCtx = await setupCtx(audio, undefined, 'model').catch( (error) => console.warn(error)); - let worker; - if (offlineCtx) { - offlineCtx.startRendering().then((resampled) => { - const myArray = resampled.getChannelData(0); - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - DEBUG && console.log('chunkstart:', chunkStart, 'file', file) - feedChunksToModel(myArray, chunkStart, file, end, worker); - chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; - }).catch((error) => { - console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); - const fileIndex = filesBeingProcessed.indexOf(file); - if (fileIndex !== -1) { - filesBeingProcessed.splice(fileIndex, 1) - } - }); - } else { - console.warn('Short chunk', chunk.length, 'padding') - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - - // Create array with 0's (short segment of silence that will trigger the finalChunk flag - const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); - feedChunksToModel(myArray, chunkStart, file, end); - console.log('chunkstart:', chunkStart, 'file', file) - - } - } - } - }); - - STREAM.on('error', err => { - console.log('stream error: ', err); - err.code === 'ENOENT' && notifyMissingFile(file); - }) + // // Create a compressor node + // const compressor = new DynamicsCompressorNode(offlineCtx, { + // threshold: -30, + // knee: 6, + // ratio: 6, + // attack: 0, + // release: 0, + // }); + // previousFilter = offlineSource.connect(compressor) ; + + + // previousFilter ? previousFilter.connect(offlineCtx.destination) : offlineSource.connect(offlineCtx.destination); + + + // // Create a highshelf filter to boost or attenuate high-frequency content + // const highshelfFilter = offlineCtx.createBiquadFilter(); + // highshelfFilter.type = 'highshelf'; + // highshelfFilter.frequency.value = STATE.highPassFrequency || 0; // This sets the cutoff frequency of the highshelf filter to 3000 Hz + // highshelfFilter.gain.value = 0; // This sets the boost or attenuation in decibels (dB) + + + // Add audio normalizer as an Audio Worklet + // if (!normalizerNode){ + // await offlineCtx.audioWorklet.addModule('js/audio_normalizer_processor.js'); + // normalizerNode = new AudioWorkletNode(offlineCtx, 'audio-normalizer-processor'); + // } + // // Connect the nodes + // previousFilter ? previousFilter.connect(normalizerNode) : offlineSource.connect(normalizerNode); + // previousFilter = normalizerNode; + + // // Create a gain node to adjust the audio level + +}; - STREAM.on('end', async function () { - // // deal with part-full buffers - // if (concatenatedBuffer.length){ - // const audio = Buffer.concat([WAV_HEADER, concatenatedBuffer]); - // await sendBuffer(audio, chunkStart, chunkLength, end, file) - // } - // DEBUG && console.log('All chunks sent for ', file) - // STREAM.destroy() - // resolve('finished') - }) - command.run(); - }).catch(error => console.log(error)); +/** +* +* @param file +* @param start +* @param end +* @returns {Promise} +*/ + +const getWavePredictBuffers = async ({ + file = '', start = 0, end = undefined +}) => { + let chunkLength = STATE.model === 'birdnet' ? 144_000 : 72_000; + // Ensure max and min are within range + start = Math.max(0, start); + end = Math.min(metadata[file].duration, end); + if (start > metadata[file].duration) { + return + } + let meta = {}; + batchChunksToSend[file] = Math.ceil((end - start) / (BATCH_SIZE * WINDOW_SIZE)); + predictionsReceived[file] = 0; + predictionsRequested[file] = 0; + let readStream; + + // extract the header + const headerStream = fs.createReadStream(file, {start: 0, end: 4096}); + headerStream.on('data', function (chunk) { + let wav = new wavefileReader.WaveFileReader(); + try { + wav.fromBuffer(chunk); + } catch (e) { + UI.postMessage({event: 'generate-alert', message: `Cannot parse ${file}, it has an invalid wav header.`}); + headerStream.close(); + updateFilesBeingProcessed(file); + return; + } + let headerEnd; + wav.signature.subChunks.forEach(el => { + if (el['chunkId'] === 'data') { + headerEnd = el.chunkData.start; } - - async function sendBuffer(audio, chunkStart, chunkLength, end, file){ + }); + meta.header = chunk.subarray(0, headerEnd); + const byteRate = wav.fmt.byteRate; + const sample_rate = wav.fmt.sampleRate; + meta.byteStart = Math.round((start * byteRate) / sample_rate) * sample_rate; + meta.byteEnd = Math.round((end * byteRate) / sample_rate) * sample_rate; + meta.highWaterMark = byteRate * BATCH_SIZE * WINDOW_SIZE; + headerStream.destroy(); + DEBUG && console.log('Header extracted for ', file) + + + readStream = fs.createReadStream(file, { + start: meta.byteStart, end: meta.byteEnd, highWaterMark: meta.highWaterMark + }); + + + let chunkStart = start * sampleRate; + // Changed on.('data') handler because of: https://stackoverflow.com/questions/32978094/nodejs-streams-and-premature-end + readStream.on('readable', async () => { + const chunk = readStream.read(); + if (chunk === null) return; + // The stream seems to read one more byte than the end + else if (chunk.byteLength <= 1 ) { + predictionsReceived[file]++; + return + } + if (aborted) { + readStream.destroy() + return + } + + try { + let audio = Buffer.concat([meta.header, chunk]); + const offlineCtx = await setupCtx(audio, undefined, 'model'); let worker; if (offlineCtx) { @@ -1364,1887 +1189,2057 @@ const prepSummaryStatement = (included) => { workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; worker = workerInstance; feedChunksToModel(myArray, chunkStart, file, end, worker); - //chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; - // Now the async stuff is done ==> - //readStream.resume(); + chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; }).catch((error) => { console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); const fileIndex = filesBeingProcessed.indexOf(file); if (fileIndex !== -1) { - filesBeingProcessed.splice(fileIndex, 1) + canBeRemovedFromCache.push(filesBeingProcessed.splice(fileIndex, 1)) } + // Note: The promise should reject when startRendering is called a second time on an OfflineAudioContext }); } else { - if (audio.length){ - console.warn('Short chunk', audio.length, 'padding') - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - - // Create array with 0's (short segment of silence that will trigger the finalChunk flag - const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); - feedChunksToModel(myArray, chunkStart, file, end); - } + console.log('Short chunk', chunk.length, 'padding') + workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; + worker = workerInstance; + + // Create array with 0's (short segment of silence that will trigger the finalChunk flag + const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); + feedChunksToModel(myArray, chunkStart, file, end); + //readStream.resume(); } + } catch (error) { + console.warn(file, error) + //trackError(error.message, 'getWavePredictBuffers', STATE.batchSize); + //UI.postMessage({event: 'generate-alert', message: "Chirpity is struggling to handle the high number of prediction requests. You should increasse the batch size in settings to compensate for this, or risk skipping audio sections. Press Esc to abort."}) } - - /** - * Called when file first loaded, when result clicked and when saving or sending file snippets - * @param args - * @returns {Promise} - */ - const fetchAudioBuffer = async ({ - file = '', start = 0, end = metadata[file].duration - }) => { - //if (end - start < 0.1) return // prevents dataset creation barfing with v. short buffer - const stream = new PassThrough(); - // Use ffmpeg to extract the specified audio segment - return new Promise((resolve, reject) => { - let command = ffmpeg(file) - .seekInput(start) - .duration(end - start) - .format('s16le') - .audioChannels(1) // Set to mono - .audioFrequency(24_000) // Set sample rate to 24000 Hz (always - this is for wavesurfer) - .output(stream, { end:true }); - if (STATE.audio.normalise) command = command.audioFilter("loudnorm=I=-16:LRA=11:TP=-1.5"); - command.on('error', error => { - updateFilesBeingProcessed(file) - reject(new Error('Error extracting audio segment:', error)); - }); - command.on('start', function (commandLine) { - DEBUG && console.log('FFmpeg command: ' + commandLine); - }) - - command.on('end', () => { - // End the stream to signify completion - stream.end(); - }); + }) + // readStream.on('end', function () { + // //readStream.close(); + // DEBUG && console.log('All chunks sent for ', file) + // }) + readStream.on('error', err => { + console.log(`readstream error: ${err}, start: ${start}, , end: ${end}, duration: ${metadata[file].duration}`); + err.code === 'ENOENT' && notifyMissingFile(file); + }) + }) +} - const data = []; - stream.on('data', chunk => { - if (chunk.byteLength > 1) data.push(chunk); - }); +const getPredictBuffers = async ({ + file = '', start = 0, end = undefined +}) => { + let chunkLength = STATE.model === 'birdnet' ? 144_000 : 72_000; + // Ensure max and min are within range + start = Math.max(0, start); + end = Math.min(metadata[file].duration, end); + if (start > metadata[file].duration) { + return + } - stream.on('end', async () => { - if (data.length === 0) return - //Add the audio header - data.unshift(CHIRPITY_HEADER) - // Concatenate the data chunks into a single Buffer - const audio = Buffer.concat(data); - - // Navtive CHIRPITY_HEADER (24kHz) here for UI - const offlineCtx = await setupCtx(audio, sampleRate, 'UI').catch( (error) => {console.error(error.message)}); - if (offlineCtx){ - offlineCtx.startRendering().then(resampled => { - // `resampled` contains an AudioBuffer resampled at 24000Hz. - // use resampled.getChannelData(x) to get an Float32Array for channel x. - // readStream.resume(); - resolve(resampled); - }).catch((error) => { - console.error(`FetchAudio rendering failed: ${error}`); - // Note: The promise should reject when startRendering is called a second time on an OfflineAudioContext - }); - } - }); - - command.run(); - }); - } - - // Helper function to check if a given time is within daylight hours - function isDuringDaylight(datetime, lat, lon) { - const date = new Date(datetime); - const { dawn, dusk } = SunCalc.getTimes(date, lat, lon); - return datetime >= dawn && datetime <= dusk; + batchChunksToSend[file] = Math.ceil((end - start) / (BATCH_SIZE * WINDOW_SIZE)); + predictionsReceived[file] = 0; + predictionsRequested[file] = 0; + let concatenatedBuffer = Buffer.alloc(0); + const highWaterMark = 2 * sampleRate * BATCH_SIZE * WINDOW_SIZE; + const STREAM = new PassThrough({ highWaterMark: highWaterMark, end: true}); + + let chunkStart = start * sampleRate; + return new Promise((resolve, reject) => { + const command = ffmpeg(file) + .seekInput(start) + .duration(end - start) + .format('wav') + //.outputOptions('-acodec pcm_s16le') + .audioChannels(1) // Set to mono + .audioFrequency(sampleRate) // Set sample rate + .output(STREAM) + + command.on('error', error => { + updateFilesBeingProcessed(file) + if (error.message.includes('SIGKILL')) DEBUG && console.log('FFMPEG process shut down') + else { + error.message = error.message + '|' + error.stack; } + reject(console.warn('Error in ffmpeg extracting audio segment:', error.message)); + }); + command.on('start', function (commandLine) { + DEBUG && console.log('FFmpeg command: ' + commandLine); + }) + command.on('end', () => { + // End the stream to signify completion + //STREAM.end(); + }); - async function feedChunksToModel(channelData, chunkStart, file, end, worker) { - predictionsRequested[file]++; - if (worker === undefined) { - // pick a worker - this method is faster than looking for avialable workers - worker = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance + STREAM.on('readable', async () => { + let chunk = STREAM.read(); + + if (aborted) { + command.kill() + STREAM.destroy() + return + } + if (chunk === null || chunk.byteLength <= 1) { + // deal with part-full buffers + if (concatenatedBuffer.length){ + const audio = Buffer.concat([WAV_HEADER, concatenatedBuffer]); + await sendBuffer(audio, chunkStart, chunkLength, end, file) } - const objData = { - message: 'predict', - worker: worker, - fileStart: metadata[file].fileStart, - file: file, - start: chunkStart, - duration: end, - resetResults: !STATE.selection, - snr: STATE.filters.SNR, - context: STATE.detect.contextAware, - confidence: STATE.detect.confidence, - chunks: channelData - }; - if (predictWorkers[worker]) predictWorkers[worker].isAvailable = false; - predictWorkers[worker]?.postMessage(objData, [channelData.buffer]); + DEBUG && console.log('All chunks sent for ', file); + //command.kill(); + resolve('finished') } - - async function doPrediction({ - file = '', - start = 0, - end = metadata[file].duration, - }) { - if (file.endsWith('.wav')){ - await getWavePredictBuffers({ file: file, start: start, end: end }).catch( (error) => console.warn(error)); - } else { - await getPredictBuffers({ file: file, start: start, end: end }).catch( (error) => console.warn(error)); + else { + const bufferList = [concatenatedBuffer, chunk].filter(buf => buf.length > 0); + try { + concatenatedBuffer = Buffer.concat(bufferList); + } catch (error) { + console.warn(error) + //trackError(error.message, 'getPredictBuffers', STATE.batchSize); + //UI.postMessage({event: 'generate-alert', message: "Chirpity is struggling to handle the high number of prediction requests. You should increasse the batch size in settings to compensate for this, or risk skipping audio sections. Press Esc to abort."}) + } - UI.postMessage({ event: 'update-audio-duration', value: metadata[file].duration }); - } - - const speciesMatch = (path, sname) => { - const pathElements = path.split(p.sep); - const species = pathElements[pathElements.length - 2]; - sname = sname.replace(/ /g, '_'); - return species.includes(sname) - } - - const convertSpecsFromExistingSpecs = async (path) => { - path ??= '/mnt/608E21D98E21A88C/Users/simpo/PycharmProjects/Data/New_Dataset'; - const file_list = await getFiles([path], true); - for (let i = 0; i < file_list.length; i++) { - const parts = p.parse(file_list[i]); - let species = parts.dir.split(p.sep); - species = species[species.length - 1]; - const [filename, time] = parts.name.split('_'); - const [start, end] = time.split('-'); - const path_to_save = path.replace('New_Dataset', 'New_Dataset_Converted') + p.sep + species; - const file_to_save = p.join(path_to_save, parts.base); - if (fs.existsSync(file_to_save)) { - DEBUG && console.log("skipping file as it is already saved") - } else { - const file_to_analyse = parts.dir.replace('New_Dataset', 'XC_ALL_mp3') + p.sep + filename + '.mp3'; - const AudioBuffer = await fetchAudioBuffer({ - start: parseFloat(start), end: parseFloat(end), file: file_to_analyse - }) - if (AudioBuffer) { // condition to prevent barfing when audio snippet is v short i.e. fetchAudioBUffer false when < 0.1s - if (++workerInstance === NUM_WORKERS) { - workerInstance = 0; + // if we have a full buffer + if (concatenatedBuffer.length >= highWaterMark) { + chunk = concatenatedBuffer.subarray(0, highWaterMark); + concatenatedBuffer = concatenatedBuffer.subarray(highWaterMark); + const audio = Buffer.concat([WAV_HEADER, chunk]) + const offlineCtx = await setupCtx(audio, undefined, 'model').catch( (error) => console.warn(error)); + let worker; + if (offlineCtx) { + offlineCtx.startRendering().then((resampled) => { + const myArray = resampled.getChannelData(0); + workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; + worker = workerInstance; + DEBUG && console.log('chunkstart:', chunkStart, 'file', file) + feedChunksToModel(myArray, chunkStart, file, end, worker); + chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; + }).catch((error) => { + console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); + const fileIndex = filesBeingProcessed.indexOf(file); + if (fileIndex !== -1) { + filesBeingProcessed.splice(fileIndex, 1) } - const buffer = AudioBuffer.getChannelData(0); - predictWorkers[workerInstance].postMessage({ - message: 'get-spectrogram', - filepath: path_to_save, - file: parts.base, - buffer: buffer, - height: 256, - width: 384, - worker: workerInstance - }, [buffer.buffer]); - } + }); + } else { + console.warn('Short chunk', chunk.length, 'padding') + workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; + worker = workerInstance; + + // Create array with 0's (short segment of silence that will trigger the finalChunk flag + const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); + feedChunksToModel(myArray, chunkStart, file, end); + console.log('chunkstart:', chunkStart, 'file', file) + } } } - - const saveResults2DataSet = ({species, included}) => { - const rootDirectory = DATASET_SAVE_LOCATION; - sampleRate = STATE.model === 'birdnet' ? 48_000 : 24_000; - const height = 256, width = 384; - let t0 = Date.now() - let promise = Promise.resolve(); - let promises = []; - let count = 0; - let db2ResultSQL = `SELECT dateTime AS timestamp, - files.duration, - files.filestart, - files.name AS file, - position, - species.sname, - species.cname, - confidence AS score, - label, - comment - FROM records - JOIN species - ON species.id = records.speciesID - JOIN files ON records.fileID = files.id - ${filtersApplied(included) ? `WHERE speciesID IN (${prepParams(STATE.included)}` : ''}) - AND confidence >= ${STATE.detect.confidence}`; - let params = filtersApplied(included) ? STATE.included : []; - if (species) { - db2ResultSQL += ` AND species.cname = ?`; - params.push(species) - } - STATE.db.each(db2ResultSQL, ...params, async (err, result) => { - // Check for level of ambient noise activation - let ambient, threshold, value = STATE.detect.confidence; - // adding_chirpity_additions is a flag for curated files, if true we assume every detection is correct - if (!adding_chirpity_additions) { - // ambient = (result.sname2 === 'Ambient Noise' ? result.score2 : result.sname3 === 'Ambient Noise' ? result.score3 : false) - // console.log('Ambient', ambient) - // // If we have a high level of ambient noise activation, insist on a high threshold for species detection - // if (ambient && ambient > 0.2) { - // value = 0.7 - // } - // Check whether top predicted species matches folder (i.e. the searched for species) - // species not matching the top prediction sets threshold to 2000, effectively limiting treatment to manual records - threshold = speciesMatch(result.file, result.sname) ? value : 2000; - } else { - threshold = result.sname === "Ambient_Noise" ? 0 : 2000; - } - promise = promise.then(async function () { - let score = result.score; - if (score >= threshold) { - const folders = p.dirname(result.file).split(p.sep); - species = result.cname.replaceAll(' ', '_'); - const sname = result.sname.replaceAll(' ', '_'); - // score 2000 when manual id. if manual ID when doing additions put it in the species folder - const folder = adding_chirpity_additions && score !== 2000 ? 'No_call' : `${sname}~${species}`; - // get start and end from timestamp - const start = result.position; - let end = start + 3; - - // filename format: __.png - const file = `${p.basename(result.file).replace(p.extname(result.file), '')}_${start}-${end}.png`; - const filepath = p.join(rootDirectory, folder) - const file_to_save = p.join(filepath, file) - if (fs.existsSync(file_to_save)) { - DEBUG && console.log("skipping file as it is already saved") - } else { - end = Math.min(end, result.duration); - const AudioBuffer = await fetchAudioBuffer({ - start: start, end: end, file: result.file - }) - if (AudioBuffer) { // condition to prevent barfing when audio snippet is v short i.e. fetchAudioBUffer false when < 0.1s - if (++workerInstance === NUM_WORKERS) { - workerInstance = 0; - } - const buffer = AudioBuffer.getChannelData(0); - predictWorkers[workerInstance].postMessage({ - message: 'get-spectrogram', - filepath: filepath, - file: file, - buffer: buffer, - height: height, - width: width, - worker: workerInstance - }, [buffer.buffer]); - count++; - } - } - } - return new Promise(function (resolve) { - setTimeout(resolve, 0.1); - }); - }) - promises.push(promise) - }, (err) => { - if (err) return console.log(err); - Promise.all(promises).then(() => console.log(`Dataset created. ${count} files saved in ${(Date.now() - t0) / 1000} seconds`)) - }) - + }); + + STREAM.on('error', err => { + console.log('stream error: ', err); + err.code === 'ENOENT' && notifyMissingFile(file); + }) + + STREAM.on('end', async function () { + // // deal with part-full buffers + // if (concatenatedBuffer.length){ + // const audio = Buffer.concat([WAV_HEADER, concatenatedBuffer]); + // await sendBuffer(audio, chunkStart, chunkLength, end, file) + // } + // DEBUG && console.log('All chunks sent for ', file) + // STREAM.destroy() + // resolve('finished') + }) + command.run(); + }).catch(error => console.log(error)); +} + +async function sendBuffer(audio, chunkStart, chunkLength, end, file){ + const offlineCtx = await setupCtx(audio, undefined, 'model'); + let worker; + if (offlineCtx) { + offlineCtx.startRendering().then((resampled) => { + const myArray = resampled.getChannelData(0); + workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; + worker = workerInstance; + feedChunksToModel(myArray, chunkStart, file, end, worker); + //chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; + // Now the async stuff is done ==> + //readStream.resume(); + }).catch((error) => { + console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); + const fileIndex = filesBeingProcessed.indexOf(file); + if (fileIndex !== -1) { + filesBeingProcessed.splice(fileIndex, 1) } - - const onSpectrogram = async (filepath, file, width, height, data, channels) => { - await mkdir(filepath, { recursive: true }); - let image = await png.encode({ width: 384, height: 256, data: data, channels: channels }) - const file_to_save = p.join(filepath, file); - await writeFile(file_to_save, image); - DEBUG && console.log('saved:', file_to_save); - }; - - async function uploadOpus({ file, start, end, defaultName, metadata, mode }) { - const blob = await bufferToAudio({ file: file, start: start, end: end, format: 'opus', meta: metadata }); - // Populate a form with the file (blob) and filename - const formData = new FormData(); - //const timestamp = Date.now() - formData.append("thefile", blob, defaultName); - // Was the prediction a correct one? - formData.append("Chirpity_assessment", mode); - // post form data - const xhr = new XMLHttpRequest(); - xhr.responseType = 'text'; - // log response - xhr.onload = () => { - DEBUG && console.log(xhr.response); - }; - // create and send the reqeust - xhr.open('POST', 'https://birds.mattkirkland.co.uk/upload'); - xhr.send(formData); + }); + } else { + if (audio.length){ + console.warn('Short chunk', audio.length, 'padding') + workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; + worker = workerInstance; + + // Create array with 0's (short segment of silence that will trigger the finalChunk flag + const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); + feedChunksToModel(myArray, chunkStart, file, end); + } + } +} + +/** +* Called when file first loaded, when result clicked and when saving or sending file snippets +* @param args +* @returns {Promise} +*/ +const fetchAudioBuffer = async ({ + file = '', start = 0, end = metadata[file].duration +}) => { + //if (end - start < 0.1) return // prevents dataset creation barfing with v. short buffer + const stream = new PassThrough(); + // Use ffmpeg to extract the specified audio segment + return new Promise((resolve, reject) => { + let command = ffmpeg(file) + .seekInput(start) + .duration(end - start) + .format('s16le') + .audioChannels(1) // Set to mono + .audioFrequency(24_000) // Set sample rate to 24000 Hz (always - this is for wavesurfer) + .output(stream, { end:true }); + if (STATE.audio.normalise) command = command.audioFilter("loudnorm=I=-16:LRA=11:TP=-1.5"); + command.on('error', error => { + updateFilesBeingProcessed(file) + reject(new Error('Error extracting audio segment:', error)); + }); + command.on('start', function (commandLine) { + DEBUG && console.log('FFmpeg command: ' + commandLine); + }) + + command.on('end', () => { + // End the stream to signify completion + stream.end(); + }); + + const data = []; + stream.on('data', chunk => { + if (chunk.byteLength > 1) data.push(chunk); + }); + + stream.on('end', async () => { + if (data.length === 0) return + //Add the audio header + data.unshift(CHIRPITY_HEADER) + // Concatenate the data chunks into a single Buffer + const audio = Buffer.concat(data); + + // Navtive CHIRPITY_HEADER (24kHz) here for UI + const offlineCtx = await setupCtx(audio, sampleRate, 'UI').catch( (error) => {console.error(error.message)}); + if (offlineCtx){ + offlineCtx.startRendering().then(resampled => { + // `resampled` contains an AudioBuffer resampled at 24000Hz. + // use resampled.getChannelData(x) to get an Float32Array for channel x. + // readStream.resume(); + resolve(resampled); + }).catch((error) => { + console.error(`FetchAudio rendering failed: ${error}`); + // Note: The promise should reject when startRendering is called a second time on an OfflineAudioContext + }); } - - const bufferToAudio = async ({ - file = '', start = 0, end = 3, meta = {}, format = undefined - }) => { - 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' - } - - 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; - end += padding; - start = Math.max(0, start); - end = Math.min(end, metadata[file].duration); - } - - return new Promise(function (resolve) { - const bufferStream = new PassThrough(); - let ffmpgCommand = ffmpeg(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) - - if (['mp3', 'm4a', 'opus'].includes(format)) { - //if (format === 'opus') bitrate *= 1000; - ffmpgCommand = ffmpgCommand.audioBitrate(bitrate) - } else if (['flac'].includes(format)) { - ffmpgCommand = ffmpgCommand.audioQuality(quality) - } - - if (fade && padding) { - const duration = end - start; - if (start >= 1 && end <= metadata[file].duration - 1) { - ffmpgCommand = ffmpgCommand.audioFilters( - { - filter: 'afade', - options: `t=in:ss=${start}:d=1` - }, - { - filter: 'afade', - options: `t=out:st=${duration - 1}:d=1` - } - )} - } - if (STATE.audio.gain){ - ffmpgCommand = ffmpgCommand.audioFilters( - { - filter: `volume=${Math.pow(10, STATE.audio.gain / 20)}` - } - ) - } - if (STATE.filters.active) { - ffmpgCommand = ffmpgCommand.audioFilters( - { - filter: 'lowshelf', - options: `gain=${STATE.filters.lowShelfAttenuation}:f=${STATE.filters.lowShelfFrequency}` - }, - { - filter: 'highpass', - options: `f=${STATE.filters.highPassFrequency}:poles=1` - } - ) - } + }); - ffmpgCommand.on('start', function (commandLine) { - DEBUG && console.log('FFmpeg command: ' + commandLine); - }) - ffmpgCommand.on('error', (err) => { - console.log('An error occurred: ' + err.message); - }) - ffmpgCommand.on('end', function () { - DEBUG && console.log(format + " file rendered") - }) - ffmpgCommand.writeToStream(bufferStream); - - const buffers = []; - bufferStream.on('data', (buf) => { - buffers.push(buf); - }); - bufferStream.on('end', function () { - const outputBuffer = Buffer.concat(buffers); - let audio = []; - audio.push(new Int8Array(outputBuffer)) - const blob = new Blob(audio, { type: mimeType }); - resolve(blob); - }); - }) - }; - - async function saveAudio(file, start, end, filename, metadata, folder) { - const thisBlob = await bufferToAudio({ - file: file, start: start, end: end, meta: metadata - }); - if (folder) { - const buffer = Buffer.from(await thisBlob.arrayBuffer()); - fs.writeFile(p.join(folder, filename), buffer, () => { if (DEBUG) console.log('Audio file saved') }); - } - else { - UI.postMessage({event:'audio-file-to-save', file: thisBlob, filename: filename}) - } - } - - // Create a flag to indicate if parseMessage is currently being executed - let isParsing = false; + command.run(); + }); +} - // Create a queue to hold messages while parseMessage is executing - const messageQueue = []; +// Helper function to check if a given time is within daylight hours +function isDuringDaylight(datetime, lat, lon) { + const date = new Date(datetime); + const { dawn, dusk } = SunCalc.getTimes(date, lat, lon); + return datetime >= dawn && datetime <= dusk; +} - // Function to process the message queue - const processQueue = async () => { - if (!isParsing && messageQueue.length > 0) { - // Set isParsing to true to prevent concurrent executions - isParsing = true; - - // Get the first message from the queue - const message = messageQueue.shift(); +async function feedChunksToModel(channelData, chunkStart, file, end, worker) { + predictionsRequested[file]++; + if (worker === undefined) { + // pick a worker - this method is faster than looking for avialable workers + worker = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance + } + const objData = { + message: 'predict', + worker: worker, + fileStart: metadata[file].fileStart, + file: file, + start: chunkStart, + duration: end, + resetResults: !STATE.selection, + snr: STATE.filters.SNR, + context: STATE.detect.contextAware, + confidence: STATE.detect.confidence, + chunks: channelData + }; + if (predictWorkers[worker]) predictWorkers[worker].isAvailable = false; + predictWorkers[worker]?.postMessage(objData, [channelData.buffer]); +} - // Parse the message - await parseMessage(message).catch( (error) => { - console.warn("Parse message error", error, 'message was', message); - }); - // Dial down the getSummary calls if the queue length starts growing - // if (messageQueue.length > NUM_WORKERS * 2 ) { - // STATE.incrementor = Math.min(STATE.incrementor *= 2, 256); - // console.log('increased incrementor to ', STATE.incrementor) - // } +async function doPrediction({ + file = '', + start = 0, + end = metadata[file].duration, +}) { + if (file.endsWith('.wav')){ + await getWavePredictBuffers({ file: file, start: start, end: end }).catch( (error) => console.warn(error)); + } else { + await getPredictBuffers({ file: file, start: start, end: end }).catch( (error) => console.warn(error)); + } + + UI.postMessage({ event: 'update-audio-duration', value: metadata[file].duration }); +} - - // Set isParsing to false to allow the next message to be processed - isParsing = false; - - // Process the next message in the queue - processQueue(); - } - }; +const speciesMatch = (path, sname) => { + const pathElements = path.split(p.sep); + const species = pathElements[pathElements.length - 2]; + sname = sname.replace(/ /g, '_'); + return species.includes(sname) +} - - /// Workers From the MDN example5 - function spawnPredictWorkers(model, list, batchSize, threads) { - NUM_WORKERS = threads; - // And be ready to receive the list: - for (let i = 0; i < threads; i++) { - const workerSrc = model === 'v3' ? 'BirdNet' : model === 'birdnet' ? 'BirdNet2.4' : 'model'; - const worker = new Worker(`./js/${workerSrc}.js`, { type: 'module' }); - worker.isAvailable = true; - worker.isReady = false; - predictWorkers.push(worker) - DEBUG && console.log('loading a worker') - worker.postMessage({ - message: 'load', - model: model, - list: list, - batchSize: batchSize, - backend: BACKEND, - lat: STATE.lat, - lon: STATE.lon, - week: STATE.week, - threshold: STATE.speciesThreshold, - worker: i - }) - - // Web worker message event handler - worker.onmessage = (e) => { - // Push the message to the queue - messageQueue.push(e); - // Process the queue - processQueue(); - }; - worker.onerror = (e) => { - console.warn(`Worker ${i} is suffering, shutting it down. THe error was:`, e.message) - predictWorkers.splice(i, 1); - worker.terminate(); - } - } - } - - const terminateWorkers = () => { - predictWorkers.forEach(worker => { - worker.postMessage({ message: 'abort' }) - worker.terminate() - }) - predictWorkers = []; - } - - async function batchInsertRecords(cname, label, files, originalCname) { - const db = STATE.db; - let params = [originalCname, STATE.detect.confidence]; - t0 = Date.now(); - let query = `SELECT * FROM records WHERE speciesID = (SELECT id FROM species WHERE cname = ?) AND confidence >= ? `; - if (STATE.mode !== 'explore') { - query += ` AND fileID in (SELECT id FROM files WHERE name IN (${files.map(() => '?').join(', ')}))` - params.push(...files); - } else if (STATE.explore.range.start) { - query += ` AND dateTime BETWEEN ${STATE.explore.range.start} AND ${STATE.explore.range.end}`; - } - const records = await STATE.db.allAsync(query, ...params); - const updatedID = db.getAsync('SELECT id FROM species WHERE cname = ?', cname); - let count = 0; - await db.runAsync('BEGIN'); - for (let i = 0; i< records.length; i++) { - const item = records[i]; - const { dateTime, speciesID, fileID, position, end, comment, callCount } = item; - const { name } = await STATE.db.getAsync('SELECT name FROM files WHERE id = ?', fileID) - // Delete existing record - const changes = await db.runAsync('DELETE FROM records WHERE datetime = ? AND speciesID = ? AND fileID = ?', dateTime, speciesID, fileID) - count += await onInsertManualRecord({ - cname: cname, - start: position, - end: end, - comment: comment, - count: callCount, - file: name, - label: label, - batch: false, - originalCname: undefined, - updateResults: i === records.length -1 // trigger a UI update after the last item - }) - } - await db.runAsync('END'); - DEBUG && console.log(`Batch record update took ${(Date.now() - t0) / 1000} seconds`) - } - - const onInsertManualRecord = async ({ cname, start, end, comment, count, file, label, batch, originalCname, confidence, speciesFiltered, updateResults = true }) => { - if (batch) return batchInsertRecords(cname, label, file, originalCname) - start = parseFloat(start), end = parseFloat(end); - const startMilliseconds = Math.round(start * 1000); - let changes = 0, fileID, fileStart; - const db = STATE.db; - const { speciesID } = await db.getAsync(`SELECT id as speciesID FROM species WHERE cname = ?`, cname); - let res = await db.getAsync(`SELECT id,filestart FROM files WHERE name = ?`, file); - - if (!res) { - // 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); - fileID = res.lastID; - changes = 1; - let durationSQL = Object.entries(metadata[file].dateDuration) - .map(entry => `(${entry.toString()},${fileID})`).join(','); - await db.runAsync(`INSERT OR IGNORE INTO duration VALUES ${durationSQL}`); - } else { - fileID = res.id; - fileStart = res.filestart; - } - - const dateTime = fileStart + startMilliseconds; - const isDaylight = isDuringDaylight(dateTime, STATE.lat, STATE.lon); - confidence = confidence || 2000; - // Delete an existing record if it exists - const result = await db.getAsync(`SELECT id as originalSpeciesID FROM species WHERE cname = ?`, originalCname); - result?.originalSpeciesID && await db.runAsync('DELETE FROM records WHERE datetime = ? AND speciesID = ? AND fileID = ?', dateTime, result.originalSpeciesID, fileID) - const response = await db.runAsync('INSERT OR REPLACE INTO records VALUES ( ?,?,?,?,?,?,?,?,?,?)', - dateTime, start, fileID, speciesID, confidence, label, comment, end, parseInt(count), isDaylight); - - if (response.changes){ - STATE.db === diskDB ? UI.postMessage({ event: 'diskDB-has-records' }) : UI.postMessage({event: 'unsaved-records'}); - } - if (updateResults){ - const select = {start: start, dateTime: dateTime}; - await getResults({species:speciesFiltered, select: select}); - await getSummary({species: speciesFiltered}); - } - return changes; +const convertSpecsFromExistingSpecs = async (path) => { + path ??= '/mnt/608E21D98E21A88C/Users/simpo/PycharmProjects/Data/New_Dataset'; + const file_list = await getFiles([path], true); + for (let i = 0; i < file_list.length; i++) { + const parts = p.parse(file_list[i]); + let species = parts.dir.split(p.sep); + species = species[species.length - 1]; + const [filename, time] = parts.name.split('_'); + const [start, end] = time.split('-'); + const path_to_save = path.replace('New_Dataset', 'New_Dataset_Converted') + p.sep + species; + const file_to_save = p.join(path_to_save, parts.base); + if (fs.existsSync(file_to_save)) { + DEBUG && console.log("skipping file as it is already saved") + } else { + const file_to_analyse = parts.dir.replace('New_Dataset', 'XC_ALL_mp3') + p.sep + filename + '.mp3'; + const AudioBuffer = await fetchAudioBuffer({ + start: parseFloat(start), end: parseFloat(end), file: file_to_analyse + }) + if (AudioBuffer) { // condition to prevent barfing when audio snippet is v short i.e. fetchAudioBUffer false when < 0.1s + if (++workerInstance === NUM_WORKERS) { + workerInstance = 0; } - - const generateInsertQuery = async (latestResult, file) => { - const db = STATE.db; - await db.runAsync('BEGIN'); - let insertQuery = 'INSERT OR IGNORE INTO records VALUES '; - let fileID, changes; - let res = await db.getAsync('SELECT id FROM files WHERE name = ?', file); - if (!res) { - res = await db.runAsync('INSERT OR IGNORE INTO files VALUES ( ?,?,?,?,? )', - undefined, file, metadata[file].duration, metadata[file].fileStart, undefined); - fileID = res.lastID; - changes = 1; - } else { - fileID = res.id; - } - if (changes) { - const durationSQL = Object.entries(metadata[file].dateDuration) - .map(entry => `(${entry.toString()},${fileID})`).join(','); - // No "OR IGNORE" in this statement because it should only run when the file is new - await db.runAsync(`INSERT OR IGNORE INTO duration VALUES ${durationSQL}`); - } - let [keysArray, speciesIDBatch, confidenceBatch] = latestResult; - for (let i = 0; i < keysArray.length; i++) { - const key = parseFloat(keysArray[i]); - const timestamp = metadata[file].fileStart + key * 1000; - const isDaylight = isDuringDaylight(timestamp, STATE.lat, STATE.lon); - const confidenceArray = confidenceBatch[i]; - const speciesIDArray = speciesIDBatch[i]; - for (let j = 0; j < confidenceArray.length; j++) { - const confidence = Math.round(confidenceArray[j] * 1000); - if (confidence < 50) break; - const speciesID = speciesIDArray[j]; - insertQuery += `(${timestamp}, ${key}, ${fileID}, ${speciesID}, ${confidence}, null, null, ${key + 3}, null, ${isDaylight}), `; - } - } - // Remove the trailing comma and space - insertQuery = insertQuery.slice(0, -2); - //DEBUG && console.log(insertQuery); - // Make sure we have some values to INSERT - insertQuery.endsWith(')') && await db.runAsync(insertQuery) - .catch( (error) => console.log("Database error:", error)) - await db.runAsync('END'); - return fileID - } - - 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; - DEBUG && console.log('worker being used:', response.worker); - if (! STATE.selection) await generateInsertQuery(latestResult, file).catch( (error) => console.log('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.log('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); - const progress = received / total; - const fileProgress = predictionsReceived[file] / batchChunksToSend[file]; - UI.postMessage({ event: 'progress', progress: progress, file: file }); - if (fileProgress === 1) { - if (index === 0 ) { - const result = `No detections found in ${file}. Searched for records using the ${STATE.list} list and having a minimum confidence of ${STATE.detect.confidence/10}%` - UI.postMessage({ - event: 'new-result', - file: file, - result: result, - index: index, - selection: STATE.selection - }); - } - 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 }); - return response.worker - } - - let SEEN_MODEL_READY = false; - async function parseMessage(e) { - const response = e.data; - // Update this worker's avaialability - predictWorkers[response.worker].isAvailable = true; - - switch (response['message']) { - case "model-ready": { - predictWorkers[response.worker].isReady = true; - if ( !SEEN_MODEL_READY) { - SEEN_MODEL_READY = true; - sampleRate = response["sampleRate"]; - const backend = response["backend"]; - console.log(backend); - UI.postMessage({ - event: "model-ready", - message: "ready", - backend: backend, - sampleRate: sampleRate - }); - } - break; - } - case "prediction": { - if ( !aborted) { - predictWorkers[response.worker].isAvailable = true; - let worker = await parsePredictions(response).catch( (error) => console.log('Error parsing predictions', error)); - DEBUG && console.log('predictions left for', response.file, predictionsReceived[response.file] - predictionsRequested[response.file]) - const remaining = predictionsReceived[response.file] - predictionsRequested[response.file] - if (remaining === 0) { - if (filesBeingProcessed.length) { - processNextFile({ - worker: worker - }); - } else if ( !STATE.selection) { - getSummary(); - UI.postMessage({ - event: "analysis-complete" - }); - } - } - } - break; - } - case "spectrogram": {onSpectrogram(response["filepath"], response["file"], response["width"], response["height"], response["image"], response["channels"]); - break; - } - + const buffer = AudioBuffer.getChannelData(0); + predictWorkers[workerInstance].postMessage({ + message: 'get-spectrogram', + filepath: path_to_save, + file: parts.base, + buffer: buffer, + height: 256, + width: 384, + worker: workerInstance + }, [buffer.buffer]); } } - - /** - * Called when a files processing is finished - * @param {*} file - */ - function updateFilesBeingProcessed(file) { - // This method to determine batch complete - const fileIndex = filesBeingProcessed.indexOf(file); - if (fileIndex !== -1) { - // canBeRemovedFromCache.push(filesBeingProcessed.splice(fileIndex, 1)) - filesBeingProcessed.splice(fileIndex, 1) - UI.postMessage({ event: 'progress', progress: 1, file: file }) - } - if (!filesBeingProcessed.length) { - if (!STATE.selection) getSummary(); - // Need this here in case last file is not sent for analysis (e.g. nocmig mode) - UI.postMessage({event: 'processing-complete'}) - } + } +} + +const saveResults2DataSet = ({species, included}) => { + const rootDirectory = DATASET_SAVE_LOCATION; + sampleRate = STATE.model === 'birdnet' ? 48_000 : 24_000; + const height = 256, width = 384; + let t0 = Date.now() + let promise = Promise.resolve(); + let promises = []; + let count = 0; + let db2ResultSQL = `SELECT dateTime AS timestamp, + files.duration, + files.filestart, + files.name AS file, + position, + species.sname, + species.cname, + confidence AS score, + label, + comment + FROM records + JOIN species + ON species.id = records.speciesID + JOIN files ON records.fileID = files.id + ${filtersApplied(included) ? `WHERE speciesID IN (${prepParams(STATE.included)}` : ''}) + AND confidence >= ${STATE.detect.confidence}`; + let params = filtersApplied(included) ? STATE.included : []; + if (species) { + db2ResultSQL += ` AND species.cname = ?`; + params.push(species) + } + STATE.db.each(db2ResultSQL, ...params, async (err, result) => { + // Check for level of ambient noise activation + let ambient, threshold, value = STATE.detect.confidence; + // adding_chirpity_additions is a flag for curated files, if true we assume every detection is correct + if (!adding_chirpity_additions) { + // ambient = (result.sname2 === 'Ambient Noise' ? result.score2 : result.sname3 === 'Ambient Noise' ? result.score3 : false) + // console.log('Ambient', ambient) + // // If we have a high level of ambient noise activation, insist on a high threshold for species detection + // if (ambient && ambient > 0.2) { + // value = 0.7 + // } + // Check whether top predicted species matches folder (i.e. the searched for species) + // species not matching the top prediction sets threshold to 2000, effectively limiting treatment to manual records + threshold = speciesMatch(result.file, result.sname) ? value : 2000; + } else { + threshold = result.sname === "Ambient_Noise" ? 0 : 2000; } - - - // Optional Arguments - async function processNextFile({ - start = undefined, end = undefined, worker = undefined - } = {}) { - if (FILE_QUEUE.length) { - let file = FILE_QUEUE.shift() - const found = await getWorkingFile(file).catch( (error) => console.warn('Error in getWorkingFile', error.message)); - if (found) { - if (end) {} - let boundaries = []; - if (!start) boundaries = await setStartEnd(file).catch( (error) => console.warn('Error in setStartEnd', error.message)); - else boundaries.push({ start: start, end: end }); - for (let i = 0; i < boundaries.length; i++) { - const { start, end } = boundaries[i]; - if (start === end) { - // Nothing to do for this file - - updateFilesBeingProcessed(file); - const result = `No detections. ${file} has no period within it where predictions would be given. Tip: To see detections in this file, disable nocmig mode.`; - - UI.postMessage({ - event: 'new-result', file: file, result: result, index: index - }); - - DEBUG && console.log('Recursion: start = end') - await processNextFile(arguments[0]).catch( (error) => console.warn('Error in processNextFile call', error.message)); - - } else { - if (!sumObjectValues(predictionsReceived)) { - UI.postMessage({ - event: 'progress', - text: "Awaiting detections", - file: file - }); - } - await doPrediction({ - start: start, end: end, file: file, worker: worker - }).catch( (error) => console.warn('Error in doPrediction', error, 'file', file, 'start', start, 'end', end)); + promise = promise.then(async function () { + let score = result.score; + if (score >= threshold) { + const folders = p.dirname(result.file).split(p.sep); + species = result.cname.replaceAll(' ', '_'); + const sname = result.sname.replaceAll(' ', '_'); + // score 2000 when manual id. if manual ID when doing additions put it in the species folder + const folder = adding_chirpity_additions && score !== 2000 ? 'No_call' : `${sname}~${species}`; + // get start and end from timestamp + const start = result.position; + let end = start + 3; + + // filename format: __.png + const file = `${p.basename(result.file).replace(p.extname(result.file), '')}_${start}-${end}.png`; + const filepath = p.join(rootDirectory, folder) + const file_to_save = p.join(filepath, file) + if (fs.existsSync(file_to_save)) { + DEBUG && console.log("skipping file as it is already saved") + } else { + end = Math.min(end, result.duration); + const AudioBuffer = await fetchAudioBuffer({ + start: start, end: end, file: result.file + }) + if (AudioBuffer) { // condition to prevent barfing when audio snippet is v short i.e. fetchAudioBUffer false when < 0.1s + if (++workerInstance === NUM_WORKERS) { + workerInstance = 0; } + const buffer = AudioBuffer.getChannelData(0); + predictWorkers[workerInstance].postMessage({ + message: 'get-spectrogram', + filepath: filepath, + file: file, + buffer: buffer, + height: height, + width: width, + worker: workerInstance + }, [buffer.buffer]); + count++; } - } else { - DEBUG && console.log('Recursion: file not found') - await processNextFile(arguments[0]).catch( (error) => console.warn('Error in recursive processNextFile call', error.message)); } } + return new Promise(function (resolve) { + setTimeout(resolve, 0.1); + }); + }) + promises.push(promise) + }, (err) => { + if (err) return console.log(err); + Promise.all(promises).then(() => console.log(`Dataset created. ${count} files saved in ${(Date.now() - t0) / 1000} seconds`)) + }) + +} + +const onSpectrogram = async (filepath, file, width, height, data, channels) => { + await mkdir(filepath, { recursive: true }); + let image = await png.encode({ width: 384, height: 256, data: data, channels: channels }) + const file_to_save = p.join(filepath, file); + await writeFile(file_to_save, image); + DEBUG && console.log('saved:', file_to_save); +}; + +async function uploadOpus({ file, start, end, defaultName, metadata, mode }) { + const blob = await bufferToAudio({ file: file, start: start, end: end, format: 'opus', meta: metadata }); + // Populate a form with the file (blob) and filename + const formData = new FormData(); + //const timestamp = Date.now() + formData.append("thefile", blob, defaultName); + // Was the prediction a correct one? + formData.append("Chirpity_assessment", mode); + // post form data + const xhr = new XMLHttpRequest(); + xhr.responseType = 'text'; + // log response + xhr.onload = () => { + DEBUG && console.log(xhr.response); + }; + // create and send the reqeust + xhr.open('POST', 'https://birds.mattkirkland.co.uk/upload'); + xhr.send(formData); +} + +const bufferToAudio = async ({ + file = '', start = 0, end = 3, meta = {}, format = undefined +}) => { + 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' + } + + 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; + end += padding; + start = Math.max(0, start); + end = Math.min(end, metadata[file].duration); + } + + return new Promise(function (resolve) { + const bufferStream = new PassThrough(); + let ffmpgCommand = ffmpeg(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) - function sumObjectValues(obj) { - return Object.values(obj).reduce((total, value) => total + value, 0); - } - - function onSameDay(timestamp1, timestamp2) { - const date1Str = new Date(timestamp1).toLocaleDateString(); - const date2Str = new Date(timestamp2).toLocaleDateString(); - return date1Str === date2Str; + if (['mp3', 'm4a', 'opus'].includes(format)) { + //if (format === 'opus') bitrate *= 1000; + ffmpgCommand = ffmpgCommand.audioBitrate(bitrate) + } else if (['flac'].includes(format)) { + ffmpgCommand = ffmpgCommand.audioQuality(quality) } - - // Function to calculate the active intervals for an audio file in nocmig mode - - function calculateNighttimeBoundaries(fileStart, fileEnd, latitude, longitude) { - const activeIntervals = []; - const maxFileOffset = (fileEnd - fileStart) / 1000; - const dayNightBoundaries = []; - //testing - const startTime = new Date(fileStart); - //needed - const endTime = new Date(fileEnd); - endTime.setHours(23, 59, 59, 999); - for (let currentDay = new Date(fileStart); - currentDay <= endTime; - currentDay.setDate(currentDay.getDate() + 1)) { - const { dawn, dusk } = SunCalc.getTimes(currentDay, latitude, longitude) - dayNightBoundaries.push(dawn.getTime(), dusk.getTime()) - } - - for (let i = 0; i < dayNightBoundaries.length; i++) { - const offset = (dayNightBoundaries[i] - fileStart) / 1000; - // negative offsets are boundaries before the file starts. - // If the file starts during daylight, we move on - if (offset < 0) { - if (!isDuringDaylight(fileStart, latitude, longitude) && i > 0) { - activeIntervals.push({ start: 0 }) - } - continue; - } - // Now handle 'all daylight' files - if (offset >= maxFileOffset) { - if (isDuringDaylight(fileEnd, latitude, longitude)) { - if (!activeIntervals.length) { - activeIntervals.push({ start: 0, end: 0 }) - return activeIntervals - } + if (fade && padding) { + const duration = end - start; + if (start >= 1 && end <= metadata[file].duration - 1) { + ffmpgCommand = ffmpgCommand.audioFilters( + { + filter: 'afade', + options: `t=in:ss=${start}:d=1` + }, + { + filter: 'afade', + options: `t=out:st=${duration - 1}:d=1` } + )} + } + if (STATE.audio.gain){ + ffmpgCommand = ffmpgCommand.audioFilters( + { + filter: `volume=${Math.pow(10, STATE.audio.gain / 20)}` } - // The list pattern is [dawn, dusk, dawn, dusk,...] - // So every second item is a start trigger - if (i % 2 !== 0) { - if (offset > maxFileOffset) break; - activeIntervals.push({ start: Math.max(offset, 0) }); - // and the others are a stop trigger - } else { - activeIntervals.length || activeIntervals.push({ start: 0 }) - activeIntervals[activeIntervals.length - 1].end = Math.min(offset, maxFileOffset); + ) + } + if (STATE.filters.active) { + ffmpgCommand = ffmpgCommand.audioFilters( + { + filter: 'lowshelf', + options: `gain=${STATE.filters.lowShelfAttenuation}:f=${STATE.filters.lowShelfFrequency}` + }, + { + filter: 'highpass', + options: `f=${STATE.filters.highPassFrequency}:poles=1` } - } - activeIntervals[activeIntervals.length - 1].end ??= maxFileOffset; - return activeIntervals; + ) } + + ffmpgCommand.on('start', function (commandLine) { + DEBUG && console.log('FFmpeg command: ' + commandLine); + }) + ffmpgCommand.on('error', (err) => { + console.log('An error occurred: ' + err.message); + }) + ffmpgCommand.on('end', function () { + DEBUG && console.log(format + " file rendered") + }) + ffmpgCommand.writeToStream(bufferStream); - async function setStartEnd(file) { - const meta = metadata[file]; - let boundaries; - //let start, end; - if (STATE.detect.nocmig) { - const fileEnd = meta.fileStart + (meta.duration * 1000); - const sameDay = onSameDay(fileEnd, meta.fileStart); - const result = await STATE.db.getAsync('SELECT * FROM locations WHERE id = ?', meta.locationID); - const { lat, lon } = result ? { lat: result.lat, lon: result.lon } : { lat: STATE.lat, lon: STATE.lon }; - boundaries = calculateNighttimeBoundaries(meta.fileStart, fileEnd, lat, lon); - } else { - boundaries = [{ start: 0, end: meta.duration }]; - } - return boundaries; - } + const buffers = []; + bufferStream.on('data', (buf) => { + buffers.push(buf); + }); + bufferStream.on('end', function () { + const outputBuffer = Buffer.concat(buffers); + let audio = []; + audio.push(new Int8Array(outputBuffer)) + const blob = new Blob(audio, { type: mimeType }); + resolve(blob); + }); + }) +}; + +async function saveAudio(file, start, end, filename, metadata, folder) { + const thisBlob = await bufferToAudio({ + file: file, start: start, end: end, meta: metadata + }); + if (folder) { + const buffer = Buffer.from(await thisBlob.arrayBuffer()); + fs.writeFile(p.join(folder, filename), buffer, () => { if (DEBUG) console.log('Audio file saved') }); + } + else { + UI.postMessage({event:'audio-file-to-save', file: thisBlob, filename: filename}) + } +} + +// Create a flag to indicate if parseMessage is currently being executed +let isParsing = false; + +// Create a queue to hold messages while parseMessage is executing +const messageQueue = []; + +// Function to process the message queue +const processQueue = async () => { + if (!isParsing && messageQueue.length > 0) { + // Set isParsing to true to prevent concurrent executions + isParsing = true; + // Get the first message from the queue + const message = messageQueue.shift(); + + // Parse the message + await parseMessage(message).catch( (error) => { + console.warn("Parse message error", error, 'message was', message); + }); + // Dial down the getSummary calls if the queue length starts growing + // if (messageQueue.length > NUM_WORKERS * 2 ) { + // STATE.incrementor = Math.min(STATE.incrementor *= 2, 256); + // console.log('increased incrementor to ', STATE.incrementor) + // } + - const getSummary = async ({ - species = undefined, - active = undefined, - 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-complate'; - UI.postMessage({ - event: event, - summary: summary, - offset: offset, - audacityLabels: AUDACITY, - filterSpecies: species, - active: active, - action: action - }) - }; + // Set isParsing to false to allow the next message to be processed + isParsing = false; + // Process the next message in the queue + processQueue(); + } +}; - const getPosition = async ({species = undefined, dateTime = undefined, included = []} = {}) => { - const params = [STATE.detect.confidence]; - let positionStmt = ` - WITH ranked_records AS ( - SELECT - dateTime, - cname, - RANK() OVER (PARTITION BY fileID, dateTime ORDER BY records.confidence DESC) AS rank - FROM records - JOIN species ON records.speciesID = species.id - JOIN files ON records.fileID = files.id - WHERE confidence >= ? - `; - // If you're using the memory db, you're either anlaysing one, or all of the files - if (['analyse'].includes(STATE.mode) && STATE.filesToAnalyse.length === 1) { - positionStmt += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; - params.push(...STATE.filesToAnalyse); - } - else if (['archive'].includes(STATE.mode)) { - positionStmt += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; - params.push(...STATE.filesToAnalyse); - } - // Prioritise selection ranges - const range = STATE.selection?.start ? STATE.selection : - STATE.mode === 'explore' ? STATE.explore.range : false; - const useRange = range?.start; - if (useRange) { - positionStmt += ' AND dateTime BETWEEN ? AND ? '; - params.push(range.start,range.end) - } - if (filtersApplied(included)){ - const included = await getIncludedIDs(); - positionStmt += ` AND speciesID IN (${prepParams(included)}) `; - params.push(...included) - } - if (STATE.locationID) { - positionStmt += ` AND locationID = ? `; - params.push(STATE.locationID) - } - if (STATE.detect.nocmig){ - positionStmt += ' AND COALESCE(isDaylight, 0) != 1 '; // Backward compatibility for < v0.9. - } - - positionStmt += `) - SELECT - count(*) as count, dateTime - FROM ranked_records - WHERE rank <= ? AND dateTime < ?`; - params.push(STATE.topRankin, dateTime); - if (species) { - positionStmt+= ` AND cname = ? `; - params.push(species) - }; - const {count} = await STATE.db.getAsync(positionStmt, ...params); - return count + +/// Workers From the MDN example5 +function spawnPredictWorkers(model, list, batchSize, threads) { + NUM_WORKERS = threads; + // And be ready to receive the list: + for (let i = 0; i < threads; i++) { + const workerSrc = model === 'v3' ? 'BirdNet' : model === 'birdnet' ? 'BirdNet2.4' : 'model'; + const worker = new Worker(`./js/${workerSrc}.js`, { type: 'module' }); + worker.isAvailable = true; + worker.isReady = false; + predictWorkers.push(worker) + DEBUG && console.log('loading a worker') + worker.postMessage({ + message: 'load', + model: model, + list: list, + batchSize: batchSize, + backend: BACKEND, + lat: STATE.lat, + lon: STATE.lon, + week: STATE.week, + threshold: STATE.speciesThreshold, + worker: i + }) + + // Web worker message event handler + worker.onmessage = (e) => { + // Push the message to the queue + messageQueue.push(e); + // Process the queue + processQueue(); + }; + worker.onerror = (e) => { + console.warn(`Worker ${i} is suffering, shutting it down. THe error was:`, e.message) + predictWorkers.splice(i, 1); + worker.terminate(); } - - /** - * - * @param files: files to query for detections - * @param species: filter for SQL query - * @param limit: the pagination limit per page - * @param offset: is the SQL query offset to use - * @param topRankin: return results >= to this rank for each datetime - * @param directory: if set, will export audio of the returned results to this folder - * @param format: whether to export audio or text - * - * @returns {Promise } A count of the records retrieved - */ - const getResults = async ({ - species = undefined, - limit = STATE.limit, - offset = undefined, - topRankin = STATE.topRankin, - directory = undefined, - format = undefined, - active = undefined, - select = undefined - } = {}) => { - let confidence = STATE.detect.confidence; - const included = STATE.selection ? [] : await getIncludedIDs(); - if (select) { - const position = await getPosition({species: species, dateTime: select.dateTime, included: included}); - offset = Math.floor(position/limit) * limit; - // update the pagination - await getTotal({species: species, offset: offset, included: included}) - } - offset = offset ?? (species ? (STATE.filteredOffset[species] ?? 0) : STATE.globalOffset); - if (species) STATE.filteredOffset[species] = offset; - else STATE.update({ globalOffset: offset }); - - - let index = offset; - AUDACITY = {}; - //const params = getResultsParams(species, confidence, offset, limit, topRankin, included); - const [sql, params] = prepResultsStatement(species, limit === Infinity, included, offset, topRankin); - - const result = await STATE.db.allAsync(sql, ...params); - let formattedValues; - if (format === 'text' || format === 'eBird'){ - // CSV export. Format the values - formattedValues = await Promise.all(result.map(async (item) => { - return format === 'text' ? await formatCSVValues(item) : await formateBirdValues(item) - - })); - if (format === 'eBird'){ - // Group the data by "Start Time", "Common name", and "Species" and calculate total species count for each group - const summary = formattedValues.reduce((acc, curr) => { - const key = `${curr["Start Time"]}_${curr["Common name"]}_${curr["Species"]}`; - if (!acc[key]) { - acc[key] = { - "Common name": curr["Common name"], - // Include other fields from the original data - "Genus": curr["Genus"], - "Species": curr["Species"], - "Species Count": 0, - "Species Comments": curr["Species Comments"], - "Location Name": curr["Location Name"], - "Latitude": curr["Latitude"], - "Longitude": curr["Longitude"], - "Date": curr["Date"], - "Start Time": curr["Start Time"], - "State/Province": curr["State/Province"], - "Country": curr["Country"], - "Protocol": curr["Protocol"], - "Number of observers": curr["Number of observers"], - "Duration": curr["Duration"], - "All observations reported?": curr["All observations reported?"], - "Distance covered": curr["Distance covered"], - "Area covered": curr["Area covered"], - "Submission Comments": curr["Submission Comments"] - }; - } - // Increment total species count for the group - acc[key]["Species Count"] += curr["Species Count"]; - return acc; - }, {}); - // Convert summary object into an array of objects - formattedValues = Object.values(summary); + } +} +const terminateWorkers = () => { + predictWorkers.forEach(worker => { + worker.postMessage({ message: 'abort' }) + worker.terminate() + }) + predictWorkers = []; +} + +async function batchInsertRecords(cname, label, files, originalCname) { + const db = STATE.db; + let params = [originalCname, STATE.detect.confidence]; + t0 = Date.now(); + let query = `SELECT * FROM records WHERE speciesID = (SELECT id FROM species WHERE cname = ?) AND confidence >= ? `; + if (STATE.mode !== 'explore') { + query += ` AND fileID in (SELECT id FROM files WHERE name IN (${files.map(() => '?').join(', ')}))` + params.push(...files); + } else if (STATE.explore.range.start) { + query += ` AND dateTime BETWEEN ${STATE.explore.range.start} AND ${STATE.explore.range.end}`; + } + const records = await STATE.db.allAsync(query, ...params); + const updatedID = db.getAsync('SELECT id FROM species WHERE cname = ?', cname); + let count = 0; + await db.runAsync('BEGIN'); + for (let i = 0; i< records.length; i++) { + const item = records[i]; + const { dateTime, speciesID, fileID, position, end, comment, callCount } = item; + const { name } = await STATE.db.getAsync('SELECT name FROM files WHERE id = ?', fileID) + // Delete existing record + const changes = await db.runAsync('DELETE FROM records WHERE datetime = ? AND speciesID = ? AND fileID = ?', dateTime, speciesID, fileID) + count += await onInsertManualRecord({ + cname: cname, + start: position, + end: end, + comment: comment, + count: callCount, + file: name, + label: label, + batch: false, + originalCname: undefined, + updateResults: i === records.length -1 // trigger a UI update after the last item + }) + } + await db.runAsync('END'); + DEBUG && console.log(`Batch record update took ${(Date.now() - t0) / 1000} seconds`) +} + +const onInsertManualRecord = async ({ cname, start, end, comment, count, file, label, batch, originalCname, confidence, speciesFiltered, updateResults = true }) => { + if (batch) return batchInsertRecords(cname, label, file, originalCname) + start = parseFloat(start), end = parseFloat(end); + const startMilliseconds = Math.round(start * 1000); + let changes = 0, fileID, fileStart; + const db = STATE.db; + const { speciesID } = await db.getAsync(`SELECT id as speciesID FROM species WHERE cname = ?`, cname); + let res = await db.getAsync(`SELECT id,filestart FROM files WHERE name = ?`, file); + + if (!res) { + // 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); + fileID = res.lastID; + changes = 1; + let durationSQL = Object.entries(metadata[file].dateDuration) + .map(entry => `(${entry.toString()},${fileID})`).join(','); + await db.runAsync(`INSERT OR IGNORE INTO duration VALUES ${durationSQL}`); + } else { + fileID = res.id; + fileStart = res.filestart; + } + + const dateTime = fileStart + startMilliseconds; + const isDaylight = isDuringDaylight(dateTime, STATE.lat, STATE.lon); + confidence = confidence || 2000; + // Delete an existing record if it exists + const result = await db.getAsync(`SELECT id as originalSpeciesID FROM species WHERE cname = ?`, originalCname); + result?.originalSpeciesID && await db.runAsync('DELETE FROM records WHERE datetime = ? AND speciesID = ? AND fileID = ?', dateTime, result.originalSpeciesID, fileID) + const response = await db.runAsync('INSERT OR REPLACE INTO records VALUES ( ?,?,?,?,?,?,?,?,?,?)', + dateTime, start, fileID, speciesID, confidence, label, comment, end, parseInt(count), isDaylight); + + if (response.changes){ + STATE.db === diskDB ? UI.postMessage({ event: 'diskDB-has-records' }) : UI.postMessage({event: 'unsaved-records'}); + } + if (updateResults){ + const select = {start: start, dateTime: dateTime}; + await getResults({species:speciesFiltered, select: select}); + await getSummary({species: speciesFiltered}); + } + return changes; +} + +const generateInsertQuery = async (latestResult, file) => { + const db = STATE.db; + await db.runAsync('BEGIN'); + let insertQuery = 'INSERT OR IGNORE INTO records VALUES '; + let fileID, changes; + let res = await db.getAsync('SELECT id FROM files WHERE name = ?', file); + if (!res) { + res = await db.runAsync('INSERT OR IGNORE INTO files VALUES ( ?,?,?,?,? )', + undefined, file, metadata[file].duration, metadata[file].fileStart, undefined); + fileID = res.lastID; + changes = 1; + } else { + fileID = res.id; + } + if (changes) { + const durationSQL = Object.entries(metadata[file].dateDuration) + .map(entry => `(${entry.toString()},${fileID})`).join(','); + // No "OR IGNORE" in this statement because it should only run when the file is new + await db.runAsync(`INSERT OR IGNORE INTO duration VALUES ${durationSQL}`); + } + let [keysArray, speciesIDBatch, confidenceBatch] = latestResult; + for (let i = 0; i < keysArray.length; i++) { + const key = parseFloat(keysArray[i]); + const timestamp = metadata[file].fileStart + key * 1000; + const isDaylight = isDuringDaylight(timestamp, STATE.lat, STATE.lon); + const confidenceArray = confidenceBatch[i]; + const speciesIDArray = speciesIDBatch[i]; + for (let j = 0; j < confidenceArray.length; j++) { + const confidence = Math.round(confidenceArray[j] * 1000); + if (confidence < 50) break; + const speciesID = speciesIDArray[j]; + insertQuery += `(${timestamp}, ${key}, ${fileID}, ${speciesID}, ${confidence}, null, null, ${key + 3}, null, ${isDaylight}), `; + } + } + // Remove the trailing comma and space + insertQuery = insertQuery.slice(0, -2); + //DEBUG && console.log(insertQuery); + // Make sure we have some values to INSERT + insertQuery.endsWith(')') && await db.runAsync(insertQuery) + .catch( (error) => console.log("Database error:", error)) + await db.runAsync('END'); + return fileID +} + +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; + DEBUG && console.log('worker being used:', response.worker); + if (! STATE.selection) await generateInsertQuery(latestResult, file).catch( (error) => console.log('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; } - // Create a write stream for the CSV file - let filename = species || 'All'; - filename += '_detections.csv'; - const filePath = p.join(directory, filename); - writeToPath(filePath, formattedValues, {headers: true}) - .on('error', err => UI.postMessage({event: 'generate-alert', message: `Cannot save file ${filePath}\nbecause it is open in another application`})) - .on('finish', () => { - UI.postMessage({event: 'generate-alert', message: filePath + ' has been written successfully.'}); - }); - } - else { - for (let i = 0; i < result.length; i++) { - const r = result[i]; - if (format === 'audio') { - if (limit){ - // Audio export. Format date to YYYY-MM-DD-HH-MM-ss - 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.position + 3, filename, metadata, directory) - i === result.length - 1 && UI.postMessage({ event: 'generate-alert', message: `${result.length} files saved` }) - } - } - else if (species && STATE.mode !== 'explore') { - // get a number for the circle - const { count } = await STATE.db.getAsync(`SELECT COUNT(*) as count FROM records WHERE dateTime = ? - AND confidence >= ? and fileID = ?`, r.timestamp, confidence, r.fileID); - r.count = count; - sendResult(++index, r, true); - } else { - sendResult(++index, r, true) - } - if (i === result.length -1) UI.postMessage({event: 'processing-complete'}) - } - if (!result.length) { - if (STATE.selection) { - // No more detections in the selection - sendResult(++index, 'No detections found in the selection', true) - } else { - species = species || ''; - sendResult(++index, `No ${species} detections found using the ${STATE.list} list.`, true) + if (confidence >= confidenceRequired) { + const { cname, sname } = await memoryDB.getAsync(`SELECT cname, sname FROM species WHERE id = ${speciesID}`).catch( (error) => console.log('Error getting species name', error)); + const result = { + timestamp: timestamp, + position: key, + end: end, + file: file, + cname: cname, + sname: sname, + score: confidence } - } - } - STATE.selection || UI.postMessage({event: 'database-results-complete', active: active, select: select?.start}); - }; - - // Function to format the CSV export - async function formatCSVValues(obj) { - // Create a copy of the original object to avoid modifying it directly - const modifiedObj = { ...obj }; - // Get lat and lon - const result = await STATE.db.getAsync(` - SELECT lat, lon, place - FROM files JOIN locations on locations.id = files.locationID - WHERE files.name = ? `, modifiedObj.file); - const latitude = result?.lat || STATE.lat; - const longitude = result?.lon || STATE.lon; - const place = result?.place || STATE.place; - modifiedObj.score /= 1000; - modifiedObj.score = modifiedObj.score.toString().replace(/^2$/, 'confirmed'); - // Step 2: Multiply 'end' by 1000 and add 'timestamp' - modifiedObj.end = (modifiedObj.end - modifiedObj.position) * 1000 + modifiedObj.timestamp; - - // Step 3: Convert 'timestamp' and 'end' to a formatted string - modifiedObj.timestamp = formatDate(modifiedObj.timestamp) - const end = new Date(modifiedObj.end); - modifiedObj.end = end.toISOString().slice(0, 19).replace('T', ' '); - // Create a new object with the right headers - const newObj = {}; - newObj['File'] = modifiedObj.file - newObj['Detection start'] = modifiedObj.timestamp - newObj['Detection end'] = modifiedObj.end - newObj['Common name'] = modifiedObj.cname - newObj['Latin name'] = modifiedObj.sname - newObj['Confidence'] = modifiedObj.score - newObj['Label'] = modifiedObj.label - newObj['Comment'] = modifiedObj.comment - newObj['Call count'] = modifiedObj.callCount - newObj['File offset'] = secondsToHHMMSS(modifiedObj.position) - newObj['Latitude'] = latitude; - newObj['Longitude'] = longitude; - newObj['Place'] = place; - return newObj; - } - - // Function to format the eBird export - async function formateBirdValues(obj) { - // Create a copy of the original object to avoid modifying it directly - const modifiedObj = { ...obj }; - // Get lat and lon - const result = await STATE.db.getAsync(` - SELECT lat, lon, place - FROM files JOIN locations on locations.id = files.locationID - WHERE files.name = ? `, modifiedObj.file); - const latitude = result?.lat || STATE.lat; - const longitude = result?.lon || STATE.lon; - const place = result?.place || STATE.place; - modifiedObj.timestamp = formatDate(modifiedObj.filestart); - let [date, time] = modifiedObj.timestamp.split(' '); - const [year, month, day] = date.split('-'); - date = `${month}/${day}/${year}`; - const [hours, minutes] = time.split(':') - time = `${hours}:${minutes}`; - if (STATE.model === 'chirpity'){ - // Regular expression to match the words inside parentheses - const regex = /\(([^)]+)\)/; - const matches = modifiedObj.cname.match(regex); - // Splitting the input string based on the regular expression match - const [name, calltype] = modifiedObj.cname.split(regex); - modifiedObj.cname = name.trim(); // Output: "words words" - modifiedObj.comment ??= calltype; - } - const [genus, species] = modifiedObj.sname.split(' '); - // Create a new object with the right keys - const newObj = {}; - newObj['Common name'] = modifiedObj.cname; - newObj['Genus'] = genus; - newObj['Species'] = species; - newObj['Species Count'] = modifiedObj.callCount || 1; - newObj['Species Comments'] = modifiedObj.comment?.replace(/\r?\n/g, ' '); - newObj['Location Name'] = place; - newObj['Latitude'] = latitude; - newObj['Longitude'] = longitude; - newObj['Date'] = date; - newObj['Start Time'] = time; - newObj['State/Province'] = ''; - newObj['Country'] = ''; - newObj['Protocol'] = 'Stationary'; - newObj['Number of observers'] = '1'; - newObj['Duration'] = Math.ceil(modifiedObj.duration / 60); - newObj['All observations reported?'] = 'N'; - newObj['Distance covered'] = ''; - newObj['Area covered'] = ''; - newObj['Submission Comments'] = 'Submission initially generated from Chirpity'; - return newObj; - } - - function secondsToHHMMSS(seconds) { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const remainingSeconds = seconds % 60; - - const HH = String(hours).padStart(2, '0'); - const MM = String(minutes).padStart(2, '0'); - const SS = String(remainingSeconds).padStart(2, '0'); - - return `${HH}:${MM}:${SS}`; - } - - const formatDate = (timestamp) =>{ - const date = new Date(timestamp); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - const seconds = String(date.getSeconds()).padStart(2, '0'); - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; - } - - const sendResult = (index, result, fromDBQuery) => { - const file = result.file; - if (typeof result === 'object') { - // Convert confidence back to % value - result.score = (result.score / 10).toFixed(0) - // Recreate Audacity labels (will create filtered view of labels if filtered) - const audacity = { - timestamp: `${result.position}\t${result.position + WINDOW_SIZE}`, - cname: result.cname, - score: Number(result.score) / 100 + sendResult(++index, result, false); + // Only show the highest confidence detection, unless it's a selection analysis + if (! STATE.selection) break; }; - AUDACITY[file] ??= []; - AUDACITY[file].push(audacity); } + } + } + + predictionsReceived[file]++; + const received = sumObjectValues(predictionsReceived); + const total = sumObjectValues(batchChunksToSend); + const progress = received / total; + const fileProgress = predictionsReceived[file] / batchChunksToSend[file]; + UI.postMessage({ event: 'progress', progress: progress, file: file }); + if (fileProgress === 1) { + if (index === 0 ) { + const result = `No detections found in ${file}. Searched for records using the ${STATE.list} list and having a minimum confidence of ${STATE.detect.confidence/10}%` UI.postMessage({ event: 'new-result', file: file, result: result, index: index, - isFromDB: fromDBQuery, selection: STATE.selection - }); - }; - - - const getSavedFileInfo = async (file) => { - if (diskDB){ - // look for file in the disk DB, ignore extension - let row = await diskDB.getAsync('SELECT * FROM files LEFT JOIN locations ON files.locationID = locations.id WHERE name = ?',file); - if (!row) { - const baseName = file.replace(/^(.*)\..*$/g, '$1%'); - row = await diskDB.getAsync('SELECT * FROM files LEFT JOIN locations ON files.locationID = locations.id WHERE name LIKE (?)',baseName); - } - return row - } else { - UI.postMessage({event: 'generate-alert', message: 'The database has not finished loading. The check for the presence of the file in the archive has been skipped'}) - return undefined - } - }; - - - /** - * Transfers data in memoryDB to diskDB - * @returns {Promise} - */ - const onSave2DiskDB = async ({file}) => { - t0 = Date.now(); - if (STATE.db === diskDB) { + }); + } + 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 }); + return response.worker +} + +let SEEN_MODEL_READY = false; +async function parseMessage(e) { + const response = e.data; + // Update this worker's avaialability + predictWorkers[response.worker].isAvailable = true; + + switch (response['message']) { + case "model-ready": { + predictWorkers[response.worker].isReady = true; + if ( !SEEN_MODEL_READY) { + SEEN_MODEL_READY = true; + sampleRate = response["sampleRate"]; + const backend = response["backend"]; + console.log(backend); UI.postMessage({ - event: 'generate-alert', - message: `Records already saved, nothing to do` - }) - return // nothing to do. Also will crash if trying to update disk from disk. - } - const included = await getIncludedIDs(file); - const filterClause = filtersApplied(included) ? `AND speciesID IN (${included} )` : '' - await memoryDB.runAsync('BEGIN'); - await memoryDB.runAsync(`INSERT OR IGNORE INTO disk.files SELECT * FROM files`); - await memoryDB.runAsync(`INSERT OR IGNORE INTO disk.locations SELECT * FROM locations`); - // Set the saved flag on files' metadata - for (let file in metadata) { - metadata[file].isSaved = true - } - // Update the duration table - let response = await memoryDB.runAsync('INSERT OR IGNORE INTO disk.duration SELECT * FROM duration'); - DEBUG && console.log(response.changes + ' date durations added to disk database'); - // now update records - response = await memoryDB.runAsync(` - INSERT OR IGNORE INTO disk.records - SELECT * FROM records - WHERE confidence >= ${STATE.detect.confidence} ${filterClause} `); - DEBUG && console.log(response?.changes + ' records added to disk database'); - await memoryDB.runAsync('END'); - DEBUG && console.log("transaction ended"); - if (response?.changes) { - UI.postMessage({ event: 'diskDB-has-records' }); - if (!DATASET) { - - // Now we have saved the records, set state to DiskDB - await onChangeMode('archive'); - getLocations({ db: STATE.db, file: file }); - UI.postMessage({ - event: 'generate-alert', - message: `Database update complete, ${response.changes} records added to the archive in ${((Date.now() - t0) / 1000)} seconds`, - updateFilenamePanel: true - }) + event: "model-ready", + message: "ready", + backend: backend, + sampleRate: sampleRate + }); + } + break; + } + case "prediction": { + if ( !aborted) { + predictWorkers[response.worker].isAvailable = true; + let worker = await parsePredictions(response).catch( (error) => console.log('Error parsing predictions', error)); + DEBUG && console.log('predictions left for', response.file, predictionsReceived[response.file] - predictionsRequested[response.file]) + const remaining = predictionsReceived[response.file] - predictionsRequested[response.file] + if (remaining === 0) { + if (filesBeingProcessed.length) { + processNextFile({ + worker: worker + }); + } else if ( !STATE.selection) { + getSummary(); + UI.postMessage({ + event: "analysis-complete" + }); + } } } - }; + break; + } + case "spectrogram": {onSpectrogram(response["filepath"], response["file"], response["width"], response["height"], response["image"], response["channels"]); + break; + }} +} - const filterLocation = () => STATE.locationID ? ` AND files.locationID = ${STATE.locationID}` : ''; +/** +* Called when a files processing is finished +* @param {*} file +*/ +function updateFilesBeingProcessed(file) { + // This method to determine batch complete + const fileIndex = filesBeingProcessed.indexOf(file); + if (fileIndex !== -1) { + // canBeRemovedFromCache.push(filesBeingProcessed.splice(fileIndex, 1)) + filesBeingProcessed.splice(fileIndex, 1) + UI.postMessage({ event: 'progress', progress: 1, file: file }) + } + if (!filesBeingProcessed.length) { + if (!STATE.selection) getSummary(); + // Need this here in case last file is not sent for analysis (e.g. nocmig mode) + UI.postMessage({event: 'processing-complete'}) + } +} - const getSeasonRecords = async (species, season) => { - // Add Location filter - const locationFilter = filterLocation(); - // Because we're using stmt.prepare, we need to unescape quotes - const seasonMonth = { spring: "< '07'", autumn: " > '06'" } - return new Promise(function (resolve, reject) { - const stmt = diskDB.prepare(` - SELECT MAX(SUBSTR(DATE(records.dateTime/1000, 'unixepoch', 'localtime'), 6)) AS maxDate, - MIN(SUBSTR(DATE(records.dateTime/1000, 'unixepoch', 'localtime'), 6)) AS minDate - FROM records - JOIN species ON species.id = records.speciesID - JOIN files ON files.id = records.fileID - WHERE species.cname = (?) ${locationFilter} - AND STRFTIME('%m', - DATETIME(records.dateTime / 1000, 'unixepoch', 'localtime')) - ${seasonMonth[season]}`); - stmt.get(species, (err, row) => { - if (err) { - reject(err) - } else { - resolve(row) - } - }) - }) - }; - const getMostCalls = (species) => { - return new Promise(function (resolve, reject) { - // Add Location filter - const locationFilter = filterLocation(); - diskDB.get(` - SELECT COUNT(*) as count, - DATE(dateTime/1000, 'unixepoch', 'localtime') as date - FROM records - JOIN species on species.id = records.speciesID - JOIN files ON files.id = records.fileID - WHERE species.cname = '${prepSQL(species)}' ${locationFilter} - GROUP BY STRFTIME('%Y', DATETIME(dateTime/1000, 'unixepoch', 'localtime')), - STRFTIME('%W', DATETIME(dateTime/1000, 'unixepoch', 'localtime')), - STRFTIME('%d', DATETIME(dateTime/1000, 'unixepoch', 'localtime')) - ORDER BY count DESC LIMIT 1`, (err, row) => { - if (err) { - reject(err) - } else { - resolve(row) +// Optional Arguments +async function processNextFile({ + start = undefined, end = undefined, worker = undefined +} = {}) { + if (FILE_QUEUE.length) { + let file = FILE_QUEUE.shift() + const found = await getWorkingFile(file).catch( (error) => console.warn('Error in getWorkingFile', error.message)); + if (found) { + if (end) {} + let boundaries = []; + if (!start) boundaries = await setStartEnd(file).catch( (error) => console.warn('Error in setStartEnd', error.message)); + else boundaries.push({ start: start, end: end }); + for (let i = 0; i < boundaries.length; i++) { + const { start, end } = boundaries[i]; + if (start === end) { + // Nothing to do for this file + + updateFilesBeingProcessed(file); + const result = `No detections. ${file} has no period within it where predictions would be given. Tip: To see detections in this file, disable nocmig mode.`; + + UI.postMessage({ + event: 'new-result', file: file, result: result, index: index + }); + + DEBUG && console.log('Recursion: start = end') + await processNextFile(arguments[0]).catch( (error) => console.warn('Error in processNextFile call', error.message)); + + } else { + if (!sumObjectValues(predictionsReceived)) { + UI.postMessage({ + event: 'progress', + text: "Awaiting detections", + file: file + }); } - }) - }) + await doPrediction({ + start: start, end: end, file: file, worker: worker + }).catch( (error) => console.warn('Error in doPrediction', error, 'file', file, 'start', start, 'end', end)); + } + } + } else { + DEBUG && console.log('Recursion: file not found') + await processNextFile(arguments[0]).catch( (error) => console.warn('Error in recursive processNextFile call', error.message)); } - - const getChartTotals = ({ - species = undefined, range = {}, aggregation = 'Week' - }) => { - // Add Location filter - const locationFilter = filterLocation(); - const dateRange = range; - - // Work out sensible aggregations from hours difference in date range - const hours_diff = dateRange.start ? Math.round((dateRange.end - dateRange.start) / (1000 * 60 * 60)) : 745; - DEBUG && console.log(hours_diff, "difference in hours") - - const dateFilter = dateRange.start ? ` AND dateTime BETWEEN ${dateRange.start} AND ${dateRange.end} ` : ''; - - // Default values for grouping - let groupBy = "Year, Week"; - let orderBy = 'Year'; - let dataPoints = Math.max(52, Math.round(hours_diff / 24 / 7)); - let startDay = 0; - - // Update grouping based on aggregation parameter - if (aggregation === 'Day') { - groupBy += ", Day"; - orderBy = 'Year, Week'; - dataPoints = Math.round(hours_diff / 24); - const date = dateRange.start !== undefined ? new Date(dateRange.start) : new Date(Date.UTC(2020, 0, 0, 0, 0, 0)); - startDay = Math.floor((date - new Date(date.getFullYear(), 0, 0, 0, 0, 0)) / 1000 / 60 / 60 / 24); - } else if (aggregation === 'Hour') { - groupBy = "Hour"; - orderBy = 'CASE WHEN Hour >= 12 THEN Hour - 12 ELSE Hour + 12 END'; - dataPoints = 24; - const date = dateRange.start !== undefined ? new Date(dateRange.start) : new Date(Date.UTC(2020, 0, 0, 0, 0, 0)); - startDay = Math.floor((date - new Date(date.getFullYear(), 0, 0, 0, 0, 0)) / 1000 / 60 / 60 / 24); + } +} + +function sumObjectValues(obj) { + return Object.values(obj).reduce((total, value) => total + value, 0); +} + +function onSameDay(timestamp1, timestamp2) { + const date1Str = new Date(timestamp1).toLocaleDateString(); + const date2Str = new Date(timestamp2).toLocaleDateString(); + return date1Str === date2Str; +} + + +// Function to calculate the active intervals for an audio file in nocmig mode + +function calculateNighttimeBoundaries(fileStart, fileEnd, latitude, longitude) { + const activeIntervals = []; + const maxFileOffset = (fileEnd - fileStart) / 1000; + const dayNightBoundaries = []; + //testing + const startTime = new Date(fileStart); + //needed + const endTime = new Date(fileEnd); + endTime.setHours(23, 59, 59, 999); + for (let currentDay = new Date(fileStart); + currentDay <= endTime; + currentDay.setDate(currentDay.getDate() + 1)) { + const { dawn, dusk } = SunCalc.getTimes(currentDay, latitude, longitude) + dayNightBoundaries.push(dawn.getTime(), dusk.getTime()) + } + + for (let i = 0; i < dayNightBoundaries.length; i++) { + const offset = (dayNightBoundaries[i] - fileStart) / 1000; + // negative offsets are boundaries before the file starts. + // If the file starts during daylight, we move on + if (offset < 0) { + if (!isDuringDaylight(fileStart, latitude, longitude) && i > 0) { + activeIntervals.push({ start: 0 }) + } + continue; + } + // Now handle 'all daylight' files + if (offset >= maxFileOffset) { + if (isDuringDaylight(fileEnd, latitude, longitude)) { + if (!activeIntervals.length) { + activeIntervals.push({ start: 0, end: 0 }) + return activeIntervals + } } - - return new Promise(function (resolve, reject) { - diskDB.all(`SELECT CAST(STRFTIME('%Y', DATETIME(dateTime / 1000, 'unixepoch', 'localtime')) AS INTEGER) AS Year, - CAST(STRFTIME('%W', DATETIME(dateTime/1000, 'unixepoch', 'localtime')) AS INTEGER) AS Week, - CAST(STRFTIME('%j', DATETIME(dateTime/1000, 'unixepoch', 'localtime')) AS INTEGER) AS Day, - CAST(STRFTIME('%H', DATETIME(dateTime/1000, 'unixepoch', 'localtime')) AS INTEGER) AS Hour, - COUNT(*) as count - FROM records - JOIN species ON species.id = speciesID - JOIN files ON files.id = fileID - WHERE species.cname = '${species}' ${dateFilter} ${locationFilter} - GROUP BY ${groupBy} - ORDER BY ${orderBy};`, (err, rows) => { - if (err) { - reject(err) - } else { - resolve([rows, dataPoints, aggregation, startDay]) - } - }) - }) } - - - - const getRate = (species) => { - - return new Promise(function (resolve, reject) { - const calls = Array.from({length: 52}).fill(0); - const total = Array.from({length: 52}).fill(0); - // Add Location filter - const locationFilter = filterLocation(); - - diskDB.all(`select STRFTIME('%W', DATE(dateTime / 1000, 'unixepoch', 'localtime')) as week, COUNT(*) as calls - from records - JOIN species ON species.id = records.speciesID - JOIN files ON files.id = records.fileID - WHERE species.cname = '${species}' ${locationFilter} - group by week;`, (err, rows) => { - for (let i = 0; i < rows.length; i++) { - calls[parseInt(rows[i].week) - 1] = rows[i].calls; - } - diskDB.all("select STRFTIME('%W', DATE(duration.day / 1000, 'unixepoch', 'localtime')) as week, cast(sum(duration) as real)/3600 as total from duration group by week;", (err, rows) => { - for (let i = 0; i < rows.length; i++) { - // Round the total to 2 dp - total[parseInt(rows[i].week) - 1] = Math.round(rows[i].total * 100) / 100; - } - let rate = []; - for (let i = 0; i < calls.length; i++) { - total[i] > 0 ? rate[i] = Math.round((calls[i] / total[i]) * 100) / 100 : rate[i] = 0; - } - if (err) { - reject(err) - } else { - resolve([total, rate]) - } - }) - }) - }) + // The list pattern is [dawn, dusk, dawn, dusk,...] + // So every second item is a start trigger + if (i % 2 !== 0) { + if (offset > maxFileOffset) break; + activeIntervals.push({ start: Math.max(offset, 0) }); + // and the others are a stop trigger + } else { + activeIntervals.length || activeIntervals.push({ start: 0 }) + activeIntervals[activeIntervals.length - 1].end = Math.min(offset, maxFileOffset); + } + } + activeIntervals[activeIntervals.length - 1].end ??= maxFileOffset; + return activeIntervals; +} + +async function setStartEnd(file) { + const meta = metadata[file]; + let boundaries; + //let start, end; + if (STATE.detect.nocmig) { + const fileEnd = meta.fileStart + (meta.duration * 1000); + const sameDay = onSameDay(fileEnd, meta.fileStart); + const result = await STATE.db.getAsync('SELECT * FROM locations WHERE id = ?', meta.locationID); + const { lat, lon } = result ? { lat: result.lat, lon: result.lon } : { lat: STATE.lat, lon: STATE.lon }; + boundaries = calculateNighttimeBoundaries(meta.fileStart, fileEnd, lat, lon); + } else { + boundaries = [{ start: 0, end: meta.duration }]; + } + return boundaries; +} + + +const getSummary = async ({ + species = undefined, + active = undefined, + 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-complate'; + UI.postMessage({ + event: event, + summary: summary, + offset: offset, + audacityLabels: AUDACITY, + filterSpecies: species, + active: active, + action: action + }) +}; + + +const getPosition = async ({species = undefined, dateTime = undefined, included = []} = {}) => { + const params = [STATE.detect.confidence]; + let positionStmt = ` + WITH ranked_records AS ( + SELECT + dateTime, + cname, + RANK() OVER (PARTITION BY fileID, dateTime ORDER BY records.confidence DESC) AS rank + FROM records + JOIN species ON records.speciesID = species.id + JOIN files ON records.fileID = files.id + WHERE confidence >= ? + `; + // If you're using the memory db, you're either anlaysing one, or all of the files + if (['analyse'].includes(STATE.mode) && STATE.filesToAnalyse.length === 1) { + positionStmt += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; + params.push(...STATE.filesToAnalyse); + } + else if (['archive'].includes(STATE.mode)) { + positionStmt += ` AND name IN (${prepParams(STATE.filesToAnalyse)}) `; + params.push(...STATE.filesToAnalyse); + } + // Prioritise selection ranges + const range = STATE.selection?.start ? STATE.selection : + STATE.mode === 'explore' ? STATE.explore.range : false; + const useRange = range?.start; + if (useRange) { + positionStmt += ' AND dateTime BETWEEN ? AND ? '; + params.push(range.start,range.end) + } + if (filtersApplied(included)){ + const included = await getIncludedIDs(); + positionStmt += ` AND speciesID IN (${prepParams(included)}) `; + params.push(...included) + } + if (STATE.locationID) { + positionStmt += ` AND locationID = ? `; + params.push(STATE.locationID) + } + if (STATE.detect.nocmig){ + positionStmt += ' AND COALESCE(isDaylight, 0) != 1 '; // Backward compatibility for < v0.9. } - /** - * getDetectedSpecies generates a list of species to use in dropdowns for chart and explore mode filters - * It doesn't really make sense to use location specific filtering here, as there is a location filter in the - * page. For now, I'm just going skip the included IDs filter if location mode is selected - */ - const getDetectedSpecies = () => { - const range = STATE.explore.range; - const confidence = STATE.detect.confidence; - let sql = `SELECT cname, locationID - FROM records - JOIN species ON species.id = records.speciesID - JOIN files on records.fileID = files.id`; - - if (STATE.mode === 'explore') sql += ` WHERE confidence >= ${confidence}`; - if (STATE.list !== 'location' && filtersApplied(STATE.included)) { - sql += ` AND speciesID IN (${STATE.included.join(',')})`; - } - if (range?.start) sql += ` AND datetime BETWEEN ${range.start} AND ${range.end}`; - sql += filterLocation(); - sql += ' GROUP BY cname ORDER BY cname'; - diskDB.all(sql, (err, rows) => { - err ? console.log(err) : UI.postMessage({ event: 'seen-species-list', list: rows }) - }) + positionStmt += `) + SELECT + count(*) as count, dateTime + FROM ranked_records + WHERE rank <= ? AND dateTime < ?`; + params.push(STATE.topRankin, dateTime); + if (species) { + positionStmt+= ` AND cname = ? `; + params.push(species) }; - - /** - * getValidSpecies generates a list of species included/excluded based on settings - * For week specific lists, we need the file - * @returns Promise - */ - const getValidSpecies = async (file) => { - const included = await getIncludedIDs(file); - let excludedSpecies, includedSpecies; - let sql = `SELECT cname, sname FROM species`; - // We'll ignore Unknown Sp. here, hence length < (LABELS.length *-1*) - - if (filtersApplied(included)) { - sql += ` WHERE id IN (${included.join(',')})`; - } - sql += ' GROUP BY cname ORDER BY cname'; - includedSpecies = await diskDB.allAsync(sql) - - if (filtersApplied(included)){ - sql = sql.replace('IN', 'NOT IN'); - excludedSpecies = await diskDB.allAsync(sql); + const {count} = await STATE.db.getAsync(positionStmt, ...params); + return count +} + +/** +* +* @param files: files to query for detections +* @param species: filter for SQL query +* @param limit: the pagination limit per page +* @param offset: is the SQL query offset to use +* @param topRankin: return results >= to this rank for each datetime +* @param directory: if set, will export audio of the returned results to this folder +* @param format: whether to export audio or text +* +* @returns {Promise } A count of the records retrieved +*/ +const getResults = async ({ + species = undefined, + limit = STATE.limit, + offset = undefined, + topRankin = STATE.topRankin, + directory = undefined, + format = undefined, + active = undefined, + select = undefined +} = {}) => { + let confidence = STATE.detect.confidence; + const included = STATE.selection ? [] : await getIncludedIDs(); + if (select) { + const position = await getPosition({species: species, dateTime: select.dateTime, included: included}); + offset = Math.floor(position/limit) * limit; + // update the pagination + await getTotal({species: species, offset: offset, included: included}) + } + offset = offset ?? (species ? (STATE.filteredOffset[species] ?? 0) : STATE.globalOffset); + if (species) STATE.filteredOffset[species] = offset; + else STATE.update({ globalOffset: offset }); + + + let index = offset; + AUDACITY = {}; + //const params = getResultsParams(species, confidence, offset, limit, topRankin, included); + const [sql, params] = prepResultsStatement(species, limit === Infinity, included, offset, topRankin); + + const result = await STATE.db.allAsync(sql, ...params); + let formattedValues; + if (format === 'text' || format === 'eBird'){ + // CSV export. Format the values + formattedValues = await Promise.all(result.map(async (item) => { + return format === 'text' ? await formatCSVValues(item) : await formateBirdValues(item) + + })); + if (format === 'eBird'){ + // Group the data by "Start Time", "Common name", and "Species" and calculate total species count for each group + const summary = formattedValues.reduce((acc, curr) => { + const key = `${curr["Start Time"]}_${curr["Common name"]}_${curr["Species"]}`; + if (!acc[key]) { + acc[key] = { + "Common name": curr["Common name"], + // Include other fields from the original data + "Genus": curr["Genus"], + "Species": curr["Species"], + "Species Count": 0, + "Species Comments": curr["Species Comments"], + "Location Name": curr["Location Name"], + "Latitude": curr["Latitude"], + "Longitude": curr["Longitude"], + "Date": curr["Date"], + "Start Time": curr["Start Time"], + "State/Province": curr["State/Province"], + "Country": curr["Country"], + "Protocol": curr["Protocol"], + "Number of observers": curr["Number of observers"], + "Duration": curr["Duration"], + "All observations reported?": curr["All observations reported?"], + "Distance covered": curr["Distance covered"], + "Area covered": curr["Area covered"], + "Submission Comments": curr["Submission Comments"] + }; + } + // Increment total species count for the group + acc[key]["Species Count"] += curr["Species Count"]; + return acc; + }, {}); + // Convert summary object into an array of objects + formattedValues = Object.values(summary); + + } + // Create a write stream for the CSV file + let filename = species || 'All'; + filename += '_detections.csv'; + const filePath = p.join(directory, filename); + writeToPath(filePath, formattedValues, {headers: true}) + .on('error', err => UI.postMessage({event: 'generate-alert', message: `Cannot save file ${filePath}\nbecause it is open in another application`})) + .on('finish', () => { + UI.postMessage({event: 'generate-alert', message: filePath + ' has been written successfully.'}); + }); + } + else { + for (let i = 0; i < result.length; i++) { + const r = result[i]; + if (format === 'audio') { + if (limit){ + // Audio export. Format date to YYYY-MM-DD-HH-MM-ss + 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.position + 3, filename, metadata, directory) + i === result.length - 1 && UI.postMessage({ event: 'generate-alert', message: `${result.length} files saved` }) + } } - UI.postMessage({ event: 'valid-species-list', included: includedSpecies, excluded: excludedSpecies }) - }; - - const onUpdateFileStart = async (args) => { - let file = args.file; - const newfileMtime = Math.round(args.start + (metadata[file].duration * 1000)); - utimesSync(file, newfileMtime); - metadata[file].fileStart = args.start; - let db = STATE.db; - let row = await db.getAsync('SELECT id from files where name = ?', file); - let result; - if (!row) { - DEBUG && console.log('File not found in database, adding.'); - await db.runAsync('INSERT INTO files (id, name, duration, filestart) values (?, ?, ?, ?)', undefined, file, metadata[file].duration, args.start); - // If no file, no records, so we're done. + else if (species && STATE.mode !== 'explore') { + // get a number for the circle + const { count } = await STATE.db.getAsync(`SELECT COUNT(*) as count FROM records WHERE dateTime = ? + AND confidence >= ? and fileID = ?`, r.timestamp, confidence, r.fileID); + r.count = count; + sendResult(++index, r, true); + } else { + sendResult(++index, r, true) } - else { - const id = row.id; - const { changes } = await db.runAsync('UPDATE files SET filestart = ? where id = ?', args.start, id); - DEBUG && console.log(changes ? `Changed ${file}` : `No changes made`); - // Fill with new values - result = await db.runAsync('UPDATE records set dateTime = (position * 1000) + ? WHERE fileID = ?', args.start, id); + if (i === result.length -1) UI.postMessage({event: 'processing-complete'}) + } + if (!result.length) { + if (STATE.selection) { + // No more detections in the selection + sendResult(++index, 'No detections found in the selection', true) + } else { + species = species || ''; + sendResult(++index, `No ${species} detections found using the ${STATE.list} list.`, true) } + } + } + STATE.selection || UI.postMessage({event: 'database-results-complete', active: active, select: select?.start}); +}; + +// Function to format the CSV export +async function formatCSVValues(obj) { + // Create a copy of the original object to avoid modifying it directly + const modifiedObj = { ...obj }; + // Get lat and lon + const result = await STATE.db.getAsync(` + SELECT lat, lon, place + FROM files JOIN locations on locations.id = files.locationID + WHERE files.name = ? `, modifiedObj.file); + const latitude = result?.lat || STATE.lat; + const longitude = result?.lon || STATE.lon; + const place = result?.place || STATE.place; + modifiedObj.score /= 1000; + modifiedObj.score = modifiedObj.score.toString().replace(/^2$/, 'confirmed'); + // Step 2: Multiply 'end' by 1000 and add 'timestamp' + modifiedObj.end = (modifiedObj.end - modifiedObj.position) * 1000 + modifiedObj.timestamp; + + // Step 3: Convert 'timestamp' and 'end' to a formatted string + modifiedObj.timestamp = formatDate(modifiedObj.timestamp) + const end = new Date(modifiedObj.end); + modifiedObj.end = end.toISOString().slice(0, 19).replace('T', ' '); + // Create a new object with the right headers + const newObj = {}; + newObj['File'] = modifiedObj.file + newObj['Detection start'] = modifiedObj.timestamp + newObj['Detection end'] = modifiedObj.end + newObj['Common name'] = modifiedObj.cname + newObj['Latin name'] = modifiedObj.sname + newObj['Confidence'] = modifiedObj.score + newObj['Label'] = modifiedObj.label + newObj['Comment'] = modifiedObj.comment + newObj['Call count'] = modifiedObj.callCount + newObj['File offset'] = secondsToHHMMSS(modifiedObj.position) + newObj['Latitude'] = latitude; + newObj['Longitude'] = longitude; + newObj['Place'] = place; + return newObj; +} + +// Function to format the eBird export +async function formateBirdValues(obj) { + // Create a copy of the original object to avoid modifying it directly + const modifiedObj = { ...obj }; + // Get lat and lon + const result = await STATE.db.getAsync(` + SELECT lat, lon, place + FROM files JOIN locations on locations.id = files.locationID + WHERE files.name = ? `, modifiedObj.file); + const latitude = result?.lat || STATE.lat; + const longitude = result?.lon || STATE.lon; + const place = result?.place || STATE.place; + modifiedObj.timestamp = formatDate(modifiedObj.filestart); + let [date, time] = modifiedObj.timestamp.split(' '); + const [year, month, day] = date.split('-'); + date = `${month}/${day}/${year}`; + const [hours, minutes] = time.split(':') + time = `${hours}:${minutes}`; + if (STATE.model === 'chirpity'){ + // Regular expression to match the words inside parentheses + const regex = /\(([^)]+)\)/; + const matches = modifiedObj.cname.match(regex); + // Splitting the input string based on the regular expression match + const [name, calltype] = modifiedObj.cname.split(regex); + modifiedObj.cname = name.trim(); // Output: "words words" + modifiedObj.comment ??= calltype; + } + const [genus, species] = modifiedObj.sname.split(' '); + // Create a new object with the right keys + const newObj = {}; + newObj['Common name'] = modifiedObj.cname; + newObj['Genus'] = genus; + newObj['Species'] = species; + newObj['Species Count'] = modifiedObj.callCount || 1; + newObj['Species Comments'] = modifiedObj.comment?.replace(/\r?\n/g, ' '); + newObj['Location Name'] = place; + newObj['Latitude'] = latitude; + newObj['Longitude'] = longitude; + newObj['Date'] = date; + newObj['Start Time'] = time; + newObj['State/Province'] = ''; + newObj['Country'] = ''; + newObj['Protocol'] = 'Stationary'; + newObj['Number of observers'] = '1'; + newObj['Duration'] = Math.ceil(modifiedObj.duration / 60); + newObj['All observations reported?'] = 'N'; + newObj['Distance covered'] = ''; + newObj['Area covered'] = ''; + newObj['Submission Comments'] = 'Submission initially generated from Chirpity'; + return newObj; +} + +function secondsToHHMMSS(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + const HH = String(hours).padStart(2, '0'); + const MM = String(minutes).padStart(2, '0'); + const SS = String(remainingSeconds).padStart(2, '0'); + + return `${HH}:${MM}:${SS}`; +} + +const formatDate = (timestamp) =>{ + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +const sendResult = (index, result, fromDBQuery) => { + const file = result.file; + if (typeof result === 'object') { + // Convert confidence back to % value + result.score = (result.score / 10).toFixed(0) + // Recreate Audacity labels (will create filtered view of labels if filtered) + const audacity = { + timestamp: `${result.position}\t${result.position + WINDOW_SIZE}`, + cname: result.cname, + score: Number(result.score) / 100 }; - - - const prepSQL = (string) => string.replaceAll("''", "'").replaceAll("'", "''"); - - - async function onDelete({ - file, - start, - end, - species, - active, - // need speciesfiltered because species triggers getSummary to highlight it - speciesFiltered - }) { - const db = STATE.db; - const { id, filestart } = await db.getAsync('SELECT id, filestart from files WHERE name = ?', file); - const datetime = filestart + (parseFloat(start) * 1000); - end = parseFloat(end); - const params = [id, datetime, end]; - let sql = 'DELETE FROM records WHERE fileID = ? AND datetime = ? AND end = ?'; - if (species) { - sql += ' AND speciesID = (SELECT id FROM species WHERE cname = ?)' - params.push(species); - } - // let test = await db.allAsync('SELECT * from records WHERE speciesID = (SELECT id FROM species WHERE cname = ?)', species) - // console.log('After insert: ',JSON.stringify(test)); - let { changes } = await db.runAsync(sql, ...params); - if (changes) { - if (STATE.mode !== 'selection') { - // Update the summary table - if (speciesFiltered === false) { - delete arguments[0].species - } - await getSummary(arguments[0]); - } - // Update the seen species list - if (db === diskDB) { - getDetectedSpecies(); - } else { - UI.postMessage({event: 'unsaved-records'}); - } - } + AUDACITY[file] ??= []; + AUDACITY[file].push(audacity); + } + UI.postMessage({ + event: 'new-result', + file: file, + result: result, + index: index, + isFromDB: fromDBQuery, + selection: STATE.selection + }); +}; + + +const getSavedFileInfo = async (file) => { + if (diskDB){ + // look for file in the disk DB, ignore extension + let row = await diskDB.getAsync('SELECT * FROM files LEFT JOIN locations ON files.locationID = locations.id WHERE name = ?',file); + if (!row) { + const baseName = file.replace(/^(.*)\..*$/g, '$1%'); + row = await diskDB.getAsync('SELECT * FROM files LEFT JOIN locations ON files.locationID = locations.id WHERE name LIKE (?)',baseName); + } + return row + } else { + UI.postMessage({event: 'generate-alert', message: 'The database has not finished loading. The check for the presence of the file in the archive has been skipped'}) + return undefined + } +}; + + +/** +* Transfers data in memoryDB to diskDB +* @returns {Promise} +*/ +const onSave2DiskDB = async ({file}) => { + t0 = Date.now(); + if (STATE.db === diskDB) { + UI.postMessage({ + event: 'generate-alert', + message: `Records already saved, nothing to do` + }) + return // nothing to do. Also will crash if trying to update disk from disk. + } + const included = await getIncludedIDs(file); + const filterClause = filtersApplied(included) ? `AND speciesID IN (${included} )` : '' + await memoryDB.runAsync('BEGIN'); + await memoryDB.runAsync(`INSERT OR IGNORE INTO disk.files SELECT * FROM files`); + await memoryDB.runAsync(`INSERT OR IGNORE INTO disk.locations SELECT * FROM locations`); + // Set the saved flag on files' metadata + for (let file in metadata) { + metadata[file].isSaved = true + } + // Update the duration table + let response = await memoryDB.runAsync('INSERT OR IGNORE INTO disk.duration SELECT * FROM duration'); + DEBUG && console.log(response.changes + ' date durations added to disk database'); + // now update records + response = await memoryDB.runAsync(` + INSERT OR IGNORE INTO disk.records + SELECT * FROM records + WHERE confidence >= ${STATE.detect.confidence} ${filterClause} `); + DEBUG && console.log(response?.changes + ' records added to disk database'); + await memoryDB.runAsync('END'); + DEBUG && console.log("transaction ended"); + if (response?.changes) { + UI.postMessage({ event: 'diskDB-has-records' }); + if (!DATASET) { + + // Now we have saved the records, set state to DiskDB + await onChangeMode('archive'); + getLocations({ db: STATE.db, file: file }); + UI.postMessage({ + event: 'generate-alert', + message: `Database update complete, ${response.changes} records added to the archive in ${((Date.now() - t0) / 1000)} seconds`, + updateFilenamePanel: true + }) } - - async function onDeleteSpecies({ - species, - // need speciesfiltered because species triggers getSummary to highlight it - speciesFiltered - }) { - const db = STATE.db; - const params = [species]; - let SQL = `DELETE FROM records - WHERE speciesID = (SELECT id FROM species WHERE cname = ?)`; - if (STATE.mode === 'analyse') { - const rows = await db.allAsync(`SELECT id FROM files WHERE NAME IN (${prepParams(STATE.filesToAnalyse)})`, ...STATE.filesToAnalyse); - const ids = rows.map(row => row.id).join(','); - SQL += ` AND fileID in (${ids})`; + } +}; + +const filterLocation = () => STATE.locationID ? ` AND files.locationID = ${STATE.locationID}` : ''; + +const getSeasonRecords = async (species, season) => { + // Add Location filter + const locationFilter = filterLocation(); + // Because we're using stmt.prepare, we need to unescape quotes + const seasonMonth = { spring: "< '07'", autumn: " > '06'" } + return new Promise(function (resolve, reject) { + const stmt = diskDB.prepare(` + SELECT MAX(SUBSTR(DATE(records.dateTime/1000, 'unixepoch', 'localtime'), 6)) AS maxDate, + MIN(SUBSTR(DATE(records.dateTime/1000, 'unixepoch', 'localtime'), 6)) AS minDate + FROM records + JOIN species ON species.id = records.speciesID + JOIN files ON files.id = records.fileID + WHERE species.cname = (?) ${locationFilter} + AND STRFTIME('%m', + DATETIME(records.dateTime / 1000, 'unixepoch', 'localtime')) + ${seasonMonth[season]}`); + stmt.get(species, (err, row) => { + if (err) { + reject(err) + } else { + resolve(row) } - else if (STATE.mode === 'explore') { - const { start, end } = STATE.explore.range; - if (start) SQL += ` AND dateTime BETWEEN ${start} AND ${end}` + }) + }) +}; + +const getMostCalls = (species) => { + return new Promise(function (resolve, reject) { + // Add Location filter + const locationFilter = filterLocation(); + diskDB.get(` + SELECT COUNT(*) as count, + DATE(dateTime/1000, 'unixepoch', 'localtime') as date + FROM records + JOIN species on species.id = records.speciesID + JOIN files ON files.id = records.fileID + WHERE species.cname = '${prepSQL(species)}' ${locationFilter} + GROUP BY STRFTIME('%Y', DATETIME(dateTime/1000, 'unixepoch', 'localtime')), + STRFTIME('%W', DATETIME(dateTime/1000, 'unixepoch', 'localtime')), + STRFTIME('%d', DATETIME(dateTime/1000, 'unixepoch', 'localtime')) + ORDER BY count DESC LIMIT 1`, (err, row) => { + if (err) { + reject(err) + } else { + resolve(row) } - let { changes } = await db.runAsync(SQL, ...params); - if (changes) { - if (db === diskDB) { - // Update the seen species list - getDetectedSpecies(); - } else { - UI.postMessage({event: 'unsaved-records'}); - } + }) + }) +} + +const getChartTotals = ({ + species = undefined, range = {}, aggregation = 'Week' +}) => { + // Add Location filter + const locationFilter = filterLocation(); + const dateRange = range; + + // Work out sensible aggregations from hours difference in date range + const hours_diff = dateRange.start ? Math.round((dateRange.end - dateRange.start) / (1000 * 60 * 60)) : 745; + DEBUG && console.log(hours_diff, "difference in hours") + + const dateFilter = dateRange.start ? ` AND dateTime BETWEEN ${dateRange.start} AND ${dateRange.end} ` : ''; + + // Default values for grouping + let groupBy = "Year, Week"; + let orderBy = 'Year'; + let dataPoints = Math.max(52, Math.round(hours_diff / 24 / 7)); + let startDay = 0; + + // Update grouping based on aggregation parameter + if (aggregation === 'Day') { + groupBy += ", Day"; + orderBy = 'Year, Week'; + dataPoints = Math.round(hours_diff / 24); + const date = dateRange.start !== undefined ? new Date(dateRange.start) : new Date(Date.UTC(2020, 0, 0, 0, 0, 0)); + startDay = Math.floor((date - new Date(date.getFullYear(), 0, 0, 0, 0, 0)) / 1000 / 60 / 60 / 24); + } else if (aggregation === 'Hour') { + groupBy = "Hour"; + orderBy = 'CASE WHEN Hour >= 12 THEN Hour - 12 ELSE Hour + 12 END'; + dataPoints = 24; + const date = dateRange.start !== undefined ? new Date(dateRange.start) : new Date(Date.UTC(2020, 0, 0, 0, 0, 0)); + startDay = Math.floor((date - new Date(date.getFullYear(), 0, 0, 0, 0, 0)) / 1000 / 60 / 60 / 24); + } + + return new Promise(function (resolve, reject) { + diskDB.all(`SELECT CAST(STRFTIME('%Y', DATETIME(dateTime / 1000, 'unixepoch', 'localtime')) AS INTEGER) AS Year, + CAST(STRFTIME('%W', DATETIME(dateTime/1000, 'unixepoch', 'localtime')) AS INTEGER) AS Week, + CAST(STRFTIME('%j', DATETIME(dateTime/1000, 'unixepoch', 'localtime')) AS INTEGER) AS Day, + CAST(STRFTIME('%H', DATETIME(dateTime/1000, 'unixepoch', 'localtime')) AS INTEGER) AS Hour, + COUNT(*) as count + FROM records + JOIN species ON species.id = speciesID + JOIN files ON files.id = fileID + WHERE species.cname = '${species}' ${dateFilter} ${locationFilter} + GROUP BY ${groupBy} + ORDER BY ${orderBy};`, (err, rows) => { + if (err) { + reject(err) + } else { + resolve([rows, dataPoints, aggregation, startDay]) } - } + }) + }) +} +const getRate = (species) => { + return new Promise(function (resolve, reject) { + const calls = Array.from({length: 52}).fill(0); + const total = Array.from({length: 52}).fill(0); + // Add Location filter + const locationFilter = filterLocation(); - async function onChartRequest(args) { - DEBUG && console.log(`Getting chart for ${args.species} starting ${args.range.start}`); - const dateRange = args.range, results = {}, dataRecords = {}; - // Escape apostrophes - if (args.species) { - t0 = Date.now(); - await getSeasonRecords(args.species, 'spring') - .then((result) => { - dataRecords.earliestSpring = result['minDate']; - dataRecords.latestSpring = result['maxDate']; - }).catch((error) => { - console.log(error) - }) - - await getSeasonRecords(args.species, 'autumn') - .then((result) => { - dataRecords.earliestAutumn = result['minDate']; - dataRecords.latestAutumn = result['maxDate']; - }).catch((error) => { - console.log(error) - }) - - DEBUG && console.log(`Season chart generation took ${(Date.now() - t0) / 1000} seconds`) - t0 = Date.now(); - await getMostCalls(args.species) - .then((row) => { - row ? dataRecords.mostDetections = [row.count, row.date] : dataRecords.mostDetections = ['N/A', 'Not detected']; - }).catch((error) => { - console.log(error) - }) - - DEBUG && console.log(`Most calls chart generation took ${(Date.now() - t0) / 1000} seconds`) - t0 = Date.now(); - args.species = prepSQL(args.species); + diskDB.all(`select STRFTIME('%W', DATE(dateTime / 1000, 'unixepoch', 'localtime')) as week, COUNT(*) as calls + from records + JOIN species ON species.id = records.speciesID + JOIN files ON files.id = records.fileID + WHERE species.cname = '${species}' ${locationFilter} + group by week;`, (err, rows) => { + for (let i = 0; i < rows.length; i++) { + calls[parseInt(rows[i].week) - 1] = rows[i].calls; } - const [dataPoints, aggregation] = await getChartTotals(args) - .then(([rows, dataPoints, aggregation, startDay]) => { + diskDB.all("select STRFTIME('%W', DATE(duration.day / 1000, 'unixepoch', 'localtime')) as week, cast(sum(duration) as real)/3600 as total from duration group by week;", (err, rows) => { for (let i = 0; i < rows.length; i++) { - const year = rows[i].Year; - const week = rows[i].Week; - const day = rows[i].Day; - const hour = rows[i].Hour; - const count = rows[i].count; - // stack years - if (!(year in results)) { - results[year] = Array.from({length: dataPoints}).fill(0); - } - if (aggregation === 'Week') { - results[year][parseInt(week) - 1] = count; - } else if (aggregation === 'Day') { - results[year][parseInt(day) - startDay] = count; - } else { - // const d = new Date(dateRange.start); - // const hoursOffset = d.getHours(); - // const index = ((parseInt(day) - startDay) * 24) + (parseInt(hour) - hoursOffset); - results[year][hour] = count; - } + // Round the total to 2 dp + total[parseInt(rows[i].week) - 1] = Math.round(rows[i].total * 100) / 100; + } + let rate = []; + for (let i = 0; i < calls.length; i++) { + total[i] > 0 ? rate[i] = Math.round((calls[i] / total[i]) * 100) / 100 : rate[i] = 0; + } + if (err) { + reject(err) + } else { + resolve([total, rate]) } - return [dataPoints, aggregation] - }).catch((error) => { - console.log(error) - }) - - DEBUG && console.log(`Chart series generation took ${(Date.now() - t0) / 1000} seconds`) - t0 = Date.now(); - // If we have a years worth of data add total recording duration and rate - let total, rate; - if (dataPoints === 52) [total, rate] = await getRate(args.species) - DEBUG && console.log(`Chart rate generation took ${(Date.now() - t0) / 1000} seconds`) - const pointStart = dateRange.start ??= Date.UTC(2020, 0, 0, 0, 0, 0); - UI.postMessage({ - event: 'chart-data', // Restore species name - species: args.species ? args.species.replace("''", "'") : undefined, - results: results, - rate: rate, - total: total, - records: dataRecords, - dataPoints: dataPoints, - pointStart: pointStart, - aggregation: aggregation }) + }) + }) +} + +/** + * getDetectedSpecies generates a list of species to use in dropdowns for chart and explore mode filters + * It doesn't really make sense to use location specific filtering here, as there is a location filter in the + * page. For now, I'm just going skip the included IDs filter if location mode is selected + */ +const getDetectedSpecies = () => { + const range = STATE.explore.range; + const confidence = STATE.detect.confidence; + let sql = `SELECT cname, locationID + FROM records + JOIN species ON species.id = records.speciesID + JOIN files on records.fileID = files.id`; + + if (STATE.mode === 'explore') sql += ` WHERE confidence >= ${confidence}`; + if (STATE.list !== 'location' && filtersApplied(STATE.included)) { + sql += ` AND speciesID IN (${STATE.included.join(',')})`; + } + if (range?.start) sql += ` AND datetime BETWEEN ${range.start} AND ${range.end}`; + sql += filterLocation(); + sql += ' GROUP BY cname ORDER BY cname'; + diskDB.all(sql, (err, rows) => { + err ? console.log(err) : UI.postMessage({ event: 'seen-species-list', list: rows }) + }) +}; + +/** + * getValidSpecies generates a list of species included/excluded based on settings + * For week specific lists, we need the file + * @returns Promise + */ +const getValidSpecies = async (file) => { + const included = await getIncludedIDs(file); + let excludedSpecies, includedSpecies; + let sql = `SELECT cname, sname FROM species`; + // We'll ignore Unknown Sp. here, hence length < (LABELS.length *-1*) + + if (filtersApplied(included)) { + sql += ` WHERE id IN (${included.join(',')})`; + } + sql += ' GROUP BY cname ORDER BY cname'; + includedSpecies = await diskDB.allAsync(sql) + + if (filtersApplied(included)){ + sql = sql.replace('IN', 'NOT IN'); + excludedSpecies = await diskDB.allAsync(sql); + } + UI.postMessage({ event: 'valid-species-list', included: includedSpecies, excluded: excludedSpecies }) +}; + +const onUpdateFileStart = async (args) => { + let file = args.file; + const newfileMtime = Math.round(args.start + (metadata[file].duration * 1000)); + utimesSync(file, newfileMtime); + metadata[file].fileStart = args.start; + let db = STATE.db; + let row = await db.getAsync('SELECT id from files where name = ?', file); + let result; + if (!row) { + DEBUG && console.log('File not found in database, adding.'); + await db.runAsync('INSERT INTO files (id, name, duration, filestart) values (?, ?, ?, ?)', undefined, file, metadata[file].duration, args.start); + // If no file, no records, so we're done. + } + else { + const id = row.id; + const { changes } = await db.runAsync('UPDATE files SET filestart = ? where id = ?', args.start, id); + DEBUG && console.log(changes ? `Changed ${file}` : `No changes made`); + // Fill with new values + result = await db.runAsync('UPDATE records set dateTime = (position * 1000) + ? WHERE fileID = ?', args.start, id); + } +}; + + +const prepSQL = (string) => string.replaceAll("''", "'").replaceAll("'", "''"); + + +async function onDelete({ + file, + start, + end, + species, + active, + // need speciesfiltered because species triggers getSummary to highlight it + speciesFiltered +}) { + const db = STATE.db; + const { id, filestart } = await db.getAsync('SELECT id, filestart from files WHERE name = ?', file); + const datetime = filestart + (parseFloat(start) * 1000); + end = parseFloat(end); + const params = [id, datetime, end]; + let sql = 'DELETE FROM records WHERE fileID = ? AND datetime = ? AND end = ?'; + if (species) { + sql += ' AND speciesID = (SELECT id FROM species WHERE cname = ?)' + params.push(species); + } + // let test = await db.allAsync('SELECT * from records WHERE speciesID = (SELECT id FROM species WHERE cname = ?)', species) + // console.log('After insert: ',JSON.stringify(test)); + let { changes } = await db.runAsync(sql, ...params); + if (changes) { + if (STATE.mode !== 'selection') { + // Update the summary table + if (speciesFiltered === false) { + delete arguments[0].species + } + await getSummary(arguments[0]); + } + // Update the seen species list + if (db === diskDB) { + getDetectedSpecies(); + } else { + UI.postMessage({event: 'unsaved-records'}); + } + } +} + +async function onDeleteSpecies({ + species, + // need speciesfiltered because species triggers getSummary to highlight it + speciesFiltered +}) { + const db = STATE.db; + const params = [species]; + let SQL = `DELETE FROM records + WHERE speciesID = (SELECT id FROM species WHERE cname = ?)`; + if (STATE.mode === 'analyse') { + const rows = await db.allAsync(`SELECT id FROM files WHERE NAME IN (${prepParams(STATE.filesToAnalyse)})`, ...STATE.filesToAnalyse); + const ids = rows.map(row => row.id).join(','); + SQL += ` AND fileID in (${ids})`; + } + else if (STATE.mode === 'explore') { + const { start, end } = STATE.explore.range; + if (start) SQL += ` AND dateTime BETWEEN ${start} AND ${end}` + } + let { changes } = await db.runAsync(SQL, ...params); + if (changes) { + if (db === diskDB) { + // Update the seen species list + getDetectedSpecies(); + } else { + UI.postMessage({event: 'unsaved-records'}); } + } +} + + +async function onChartRequest(args) { + DEBUG && console.log(`Getting chart for ${args.species} starting ${args.range.start}`); + const dateRange = args.range, results = {}, dataRecords = {}; + // Escape apostrophes + if (args.species) { + t0 = Date.now(); + await getSeasonRecords(args.species, 'spring') + .then((result) => { + dataRecords.earliestSpring = result['minDate']; + dataRecords.latestSpring = result['maxDate']; + }).catch((error) => { + console.log(error) + }) - const onFileDelete = async (fileName) => { - const result = await diskDB.runAsync('DELETE FROM files WHERE name = ?', fileName); - if (result.changes) { - await onChangeMode('analyse'); - getDetectedSpecies(); - UI.postMessage({ - event: 'generate-alert', - message: `${fileName} - and its associated records were deleted successfully`, - updateFilenamePanel: true - }); - - await Promise.all([getResults(), getSummary()] ); + await getSeasonRecords(args.species, 'autumn') + .then((result) => { + dataRecords.earliestAutumn = result['minDate']; + dataRecords.latestAutumn = result['maxDate']; + }).catch((error) => { + console.log(error) + }) + + DEBUG && console.log(`Season chart generation took ${(Date.now() - t0) / 1000} seconds`) + t0 = Date.now(); + await getMostCalls(args.species) + .then((row) => { + row ? dataRecords.mostDetections = [row.count, row.date] : dataRecords.mostDetections = ['N/A', 'Not detected']; + }).catch((error) => { + console.log(error) + }) + + DEBUG && console.log(`Most calls chart generation took ${(Date.now() - t0) / 1000} seconds`) + t0 = Date.now(); + args.species = prepSQL(args.species); + } + const [dataPoints, aggregation] = await getChartTotals(args) + .then(([rows, dataPoints, aggregation, startDay]) => { + for (let i = 0; i < rows.length; i++) { + const year = rows[i].Year; + const week = rows[i].Week; + const day = rows[i].Day; + const hour = rows[i].Hour; + const count = rows[i].count; + // stack years + if (!(year in results)) { + results[year] = Array.from({length: dataPoints}).fill(0); + } + if (aggregation === 'Week') { + results[year][parseInt(week) - 1] = count; + } else if (aggregation === 'Day') { + results[year][parseInt(day) - startDay] = count; } else { - UI.postMessage({ - event: 'generate-alert', message: `${fileName} - was not found in the Archve databasse.`}); - } + // const d = new Date(dateRange.start); + // const hoursOffset = d.getHours(); + // const index = ((parseInt(day) - startDay) * 24) + (parseInt(hour) - hoursOffset); + results[year][hour] = count; } - - async function onUpdateLocale(locale, labels, refreshResults){ - let t0 = performance.now(); - await diskDB.runAsync('BEGIN'); - await memoryDB.runAsync('BEGIN'); - if (STATE.model === 'birdnet'){ - for (let i = 0; i < labels.length; i++){ - const [sname, cname] = labels[i].trim().split('_'); - await diskDB.runAsync('UPDATE species SET cname = ? WHERE sname = ?', cname, sname); - await memoryDB.runAsync('UPDATE species SET cname = ? WHERE sname = ?', cname, sname); - } - } else { - for (let i = 0; i < labels.length; i++) { - const [sname, newCname] = labels[i].split('_'); - // For chirpity, we check if the existing cname ends with a in brackets - const existingCnameResult = await memoryDB.allAsync('SELECT cname FROM species WHERE sname = ?', sname); - if (existingCnameResult.length) { - for (let i = 0; i < existingCnameResult.length; i++){ - const {cname} = existingCnameResult[i]; - const existingCname = cname; - const existingCnameMatch = existingCname.match(/\(([^)]+)\)$/); // Regex to match word(s) within brackets at the end of the string - const newCnameMatch = newCname.match(/\(([^)]+)\)$/); - // Do we have a spcific call type to match? - if (newCnameMatch){ - // then only update the database where existing and new call types match - if (newCnameMatch[0] === existingCnameMatch[0]){ - const callTypeMatch = '%' + newCnameMatch[0] + '%' ; - await diskDB.runAsync("UPDATE species SET cname = ? WHERE sname = ? AND cname LIKE ?", newCname, sname, callTypeMatch); - await memoryDB.runAsync("UPDATE species SET cname = ? WHERE sname = ? AND cname LIKE ?", newCname, sname, callTypeMatch); - } - } else { // No () in the new label - so we add the new name to all the species call types in the database - let appendedCname = newCname, bracketedWord; - if (existingCnameMatch) { - bracketedWord = existingCnameMatch[0]; - appendedCname += ` ${bracketedWord}`; // Append the bracketed word to the new cname (for each of the existingCnameResults) - const callTypeMatch = '%' + bracketedWord + '%'; - await diskDB.runAsync("UPDATE species SET cname = ? WHERE sname = ? AND cname LIKE ?", appendedCname, sname, callTypeMatch); - await memoryDB.runAsync("UPDATE species SET cname = ? WHERE sname = ? AND cname LIKE ?", appendedCname, sname, callTypeMatch); - } else { - await diskDB.runAsync("UPDATE species SET cname = ? WHERE sname = ?", appendedCname, sname); - await memoryDB.runAsync("UPDATE species SET cname = ? WHERE sname = ?", appendedCname, sname); - } - } - } + } + return [dataPoints, aggregation] + }).catch((error) => { + console.log(error) + }) + + DEBUG && console.log(`Chart series generation took ${(Date.now() - t0) / 1000} seconds`) + t0 = Date.now(); + // If we have a years worth of data add total recording duration and rate + let total, rate; + if (dataPoints === 52) [total, rate] = await getRate(args.species) + DEBUG && console.log(`Chart rate generation took ${(Date.now() - t0) / 1000} seconds`) + const pointStart = dateRange.start ??= Date.UTC(2020, 0, 0, 0, 0, 0); + UI.postMessage({ + event: 'chart-data', // Restore species name + species: args.species ? args.species.replace("''", "'") : undefined, + results: results, + rate: rate, + total: total, + records: dataRecords, + dataPoints: dataPoints, + pointStart: pointStart, + aggregation: aggregation + }) +} + +const onFileDelete = async (fileName) => { + const result = await diskDB.runAsync('DELETE FROM files WHERE name = ?', fileName); + if (result.changes) { + await onChangeMode('analyse'); + getDetectedSpecies(); + UI.postMessage({ + event: 'generate-alert', + message: `${fileName} + and its associated records were deleted successfully`, + updateFilenamePanel: true + }); + await Promise.all([getResults(), getSummary()] ); + } else { + UI.postMessage({ + event: 'generate-alert', message: `${fileName} + was not found in the Archve databasse.` + }); + } +} + +async function onUpdateLocale(locale, labels, refreshResults){ + let t0 = performance.now(); + await diskDB.runAsync('BEGIN'); + await memoryDB.runAsync('BEGIN'); + if (STATE.model === 'birdnet'){ + for (let i = 0; i < labels.length; i++){ + const [sname, cname] = labels[i].trim().split('_'); + await diskDB.runAsync('UPDATE species SET cname = ? WHERE sname = ?', cname, sname); + await memoryDB.runAsync('UPDATE species SET cname = ? WHERE sname = ?', cname, sname); + } + } else { + for (let i = 0; i < labels.length; i++) { + const [sname, newCname] = labels[i].split('_'); + // For chirpity, we check if the existing cname ends with a in brackets + const existingCnameResult = await memoryDB.allAsync('SELECT cname FROM species WHERE sname = ?', sname); + if (existingCnameResult.length) { + for (let i = 0; i < existingCnameResult.length; i++){ + const {cname} = existingCnameResult[i]; + const existingCname = cname; + const existingCnameMatch = existingCname.match(/\(([^)]+)\)$/); // Regex to match word(s) within brackets at the end of the string + const newCnameMatch = newCname.match(/\(([^)]+)\)$/); + // Do we have a spcific call type to match? + if (newCnameMatch){ + // then only update the database where existing and new call types match + if (newCnameMatch[0] === existingCnameMatch[0]){ + const callTypeMatch = '%' + newCnameMatch[0] + '%' ; + await diskDB.runAsync("UPDATE species SET cname = ? WHERE sname = ? AND cname LIKE ?", newCname, sname, callTypeMatch); + await memoryDB.runAsync("UPDATE species SET cname = ? WHERE sname = ? AND cname LIKE ?", newCname, sname, callTypeMatch); } - } - } - await diskDB.runAsync('END'); - await memoryDB.runAsync('END'); - STATE.update({locale: locale}); - if (refreshResults) await Promise.all([getResults(), getSummary()]) - } - - async function onSetCustomLocation({ lat, lon, place, files, db = STATE.db }) { - if (!place) { - const { id } = await db.getAsync(`SELECT id FROM locations WHERE lat = ? AND lon = ?`, lat, lon); - const result = await db.runAsync(`DELETE FROM locations WHERE lat = ? AND lon = ?`, lat, lon); - if (result.changes) { - await db.runAsync(`UPDATE files SET locationID = null WHERE locationID = ?`, id); - } - if (db === memoryDB) { - onSetCustomLocation({lat: lat, lon: lon, place: undefined, files: undefined, db: diskDB}) - } - } else { - const result = await db.runAsync(` - INSERT INTO locations VALUES (?, ?, ?, ?) - ON CONFLICT(lat,lon) DO UPDATE SET place = excluded.place`, undefined, lat, lon, place); - const { id } = await db.getAsync(`SELECT ID FROM locations WHERE lat = ? AND lon = ?`, lat, lon); - for (const file of files) { - await db.runAsync('UPDATE files SET locationID = ? WHERE name = ?', id, file); - // we may not have set the metadata for the file - if (metadata[file]) { - metadata[file].locationID = id; + } else { // No () in the new label - so we add the new name to all the species call types in the database + let appendedCname = newCname, bracketedWord; + if (existingCnameMatch) { + bracketedWord = existingCnameMatch[0]; + appendedCname += ` ${bracketedWord}`; // Append the bracketed word to the new cname (for each of the existingCnameResults) + const callTypeMatch = '%' + bracketedWord + '%'; + await diskDB.runAsync("UPDATE species SET cname = ? WHERE sname = ? AND cname LIKE ?", appendedCname, sname, callTypeMatch); + await memoryDB.runAsync("UPDATE species SET cname = ? WHERE sname = ? AND cname LIKE ?", appendedCname, sname, callTypeMatch); } else { - metadata[file] = {} - metadata[file].locationID = id; - metadata[file].isComplete = false; - } - // tell the UI the file has a location id - UI.postMessage({ event: 'file-location-id', file: file, id: id }); - // state.db is set onAnalyse, so check if the file is saved - if (db === memoryDB) { - const fileSaved = await getSavedFileInfo(file) - if (fileSaved) { - onSetCustomLocation({lat: lat, lon: lon, place: place, files: [file], db: diskDB}) - } + await diskDB.runAsync("UPDATE species SET cname = ? WHERE sname = ?", appendedCname, sname); + await memoryDB.runAsync("UPDATE species SET cname = ? WHERE sname = ?", appendedCname, sname); } } } - await getLocations({ db: db, file: files[0] }); - } - - async function getLocations({ db = STATE.db, file }) { - const locations = await db.allAsync('SELECT * FROM locations ORDER BY place') - UI.postMessage({ event: 'location-list', locations: locations, currentLocation: metadata[file]?.locationID }) } - - /** - * getIncludedIDs - * Helper function to provide a list of valid species for the filter. - * Will look for a list in the STATE.included cache, and if not present, - * will call setIncludedIDs to generate a new list - * @param {*} file - * @returns a list of IDs included in filtered results - */ - async function getIncludedIDs(file){ - t0 = Date.now(); - let lat, lon, week, hitOrMiss = 'hit'; - if (STATE.list === 'location' || (STATE.list === 'nocturnal' && STATE.local)){ - if (file){ - file = metadata[file]; - week = STATE.useWeek ? new Date(file.fileStart).getWeekNumber() : "-1"; - lat = file.lat || STATE.lat; - lon = file.lon || STATE.lon; - STATE.week = week; - } else { - // summary context: use the week, lat & lon from the first file?? - lat = STATE.lat, lon = STATE.lon; - week = STATE.useWeek ? STATE.week : "-1"; - } - const location = lat.toString() + lon.toString(); - if (STATE.included?.[STATE.model]?.[STATE.list]?.[week]?.[location] === undefined ) { - // Cache miss - const list = await setIncludedIDs(lat,lon,week) - hitOrMiss = 'miss'; - } - DEBUG && console.log(`Cache ${hitOrMiss}: setting the ${STATE.list} list took ${Date.now() -t0}ms`) - return STATE.included[STATE.model][STATE.list][week][location]; - - } else { - if (STATE.included?.[STATE.model]?.[STATE.list] === undefined) { - // The object lacks the week / location - await setIncludedIDs(); - hitOrMiss = 'miss'; - } - //DEBUG && console.log(`Cache ${hitOrMiss}: setting the ${STATE.list} list took ${Date.now() -t0}ms`) - return STATE.included[STATE.model][STATE.list]; + } + } + await diskDB.runAsync('END'); + await memoryDB.runAsync('END'); + STATE.update({locale: locale}); + if (refreshResults) await Promise.all([getResults(), getSummary()]) +} + +async function onSetCustomLocation({ lat, lon, place, files, db = STATE.db }) { + if (!place) { + const { id } = await db.getAsync(`SELECT id FROM locations WHERE lat = ? AND lon = ?`, lat, lon); + const result = await db.runAsync(`DELETE FROM locations WHERE lat = ? AND lon = ?`, lat, lon); + if (result.changes) { + await db.runAsync(`UPDATE files SET locationID = null WHERE locationID = ?`, id); + } + if (db === memoryDB) { + onSetCustomLocation({lat: lat, lon: lon, place: undefined, files: undefined, db: diskDB}) + } + } else { + const result = await db.runAsync(` + INSERT INTO locations VALUES (?, ?, ?, ?) + ON CONFLICT(lat,lon) DO UPDATE SET place = excluded.place`, undefined, lat, lon, place); + const { id } = await db.getAsync(`SELECT ID FROM locations WHERE lat = ? AND lon = ?`, lat, lon); + for (const file of files) { + await db.runAsync('UPDATE files SET locationID = ? WHERE name = ?', id, file); + // we may not have set the metadata for the file + if (metadata[file]) { + metadata[file].locationID = id; + } else { + metadata[file] = {} + metadata[file].locationID = id; + metadata[file].isComplete = false; + } + // tell the UI the file has a location id + UI.postMessage({ event: 'file-location-id', file: file, id: id }); + // state.db is set onAnalyse, so check if the file is saved + if (db === memoryDB) { + const fileSaved = await getSavedFileInfo(file) + if (fileSaved) { + onSetCustomLocation({lat: lat, lon: lon, place: place, files: [file], db: diskDB}) } } + } + } + await getLocations({ db: db, file: files[0] }); +} + +async function getLocations({ db = STATE.db, file }) { + const locations = await db.allAsync('SELECT * FROM locations ORDER BY place') + UI.postMessage({ event: 'location-list', locations: locations, currentLocation: metadata[file]?.locationID }) +} + +/** + * getIncludedIDs + * Helper function to provide a list of valid species for the filter. + * Will look for a list in the STATE.included cache, and if not present, + * will call setIncludedIDs to generate a new list + * @param {*} file + * @returns a list of IDs included in filtered results + */ +async function getIncludedIDs(file){ + t0 = Date.now(); + let lat, lon, week, hitOrMiss = 'hit'; + if (STATE.list === 'location' || (STATE.list === 'nocturnal' && STATE.local)){ + if (file){ + file = metadata[file]; + week = STATE.useWeek ? new Date(file.fileStart).getWeekNumber() : "-1"; + lat = file.lat || STATE.lat; + lon = file.lon || STATE.lon; + STATE.week = week; + } else { + // summary context: use the week, lat & lon from the first file?? + lat = STATE.lat, lon = STATE.lon; + week = STATE.useWeek ? STATE.week : "-1"; + } + const location = lat.toString() + lon.toString(); + if (STATE.included?.[STATE.model]?.[STATE.list]?.[week]?.[location] === undefined ) { + // Cache miss + const list = await setIncludedIDs(lat,lon,week) + hitOrMiss = 'miss'; + } + DEBUG && console.log(`Cache ${hitOrMiss}: setting the ${STATE.list} list took ${Date.now() -t0}ms`) + return STATE.included[STATE.model][STATE.list][week][location]; + + } else { + if (STATE.included?.[STATE.model]?.[STATE.list] === undefined) { + // The object lacks the week / location + await setIncludedIDs(); + hitOrMiss = 'miss'; + } + //DEBUG && console.log(`Cache ${hitOrMiss}: setting the ${STATE.list} list took ${Date.now() -t0}ms`) + return STATE.included[STATE.model][STATE.list]; + } +} - /** - * setIncludedIDs - * Calls list_worker for a new list, checks to see if a pending promise already exists - * @param {*} lat - * @param {*} lon - * @param {*} week - * @returns - */ - - let LIST_CACHE = {}; +/** + * setIncludedIDs + * Calls list_worker for a new list, checks to see if a pending promise already exists + * @param {*} lat + * @param {*} lon + * @param {*} week + * @returns + */ + +let LIST_CACHE = {}; async function setIncludedIDs(lat, lon, week) { const key = `${lat}-${lon}-${week}-${STATE.model}-${STATE.list}`; From 96ed241c8f66ec18009bbeabf41bddec0e62b446 Mon Sep 17 00:00:00 2001 From: mattk70 Date: Sun, 17 Mar 2024 20:46:00 +0000 Subject: [PATCH 3/8] serialised stream processsing refactored getpredictbuffers and getwavepredictbuffers to use single processPredictQueue function --- js/worker.js | 164 +++++++++++++++++---------------------------------- 1 file changed, 53 insertions(+), 111 deletions(-) diff --git a/js/worker.js b/js/worker.js index 78f9996c..458fa04c 100644 --- a/js/worker.js +++ b/js/worker.js @@ -733,7 +733,6 @@ async function onAnalyse({ //Reset GLOBAL variables index = 0; AUDACITY = {}; - // canBeRemovedFromCache = []; batchChunksToSend = {}; FILE_QUEUE = filesInScope; @@ -1113,6 +1112,9 @@ async function setupCtx(audio, rate, destination) { * @param end * @returns {Promise} */ + +let predictQueue = []; +let processing = false; const getWavePredictBuffers = async ({ file = '', start = 0, end = undefined @@ -1162,10 +1164,10 @@ const getWavePredictBuffers = async ({ start: meta.byteStart, end: meta.byteEnd, highWaterMark: meta.highWaterMark }); - + let chunkStart = start * sampleRate; // Changed on.('data') handler because of: https://stackoverflow.com/questions/32978094/nodejs-streams-and-premature-end - readStream.on('readable', async () => { + readStream.on('readable', () => { const chunk = readStream.read(); if (chunk === null) return; // The stream seems to read one more byte than the end @@ -1177,54 +1179,53 @@ const getWavePredictBuffers = async ({ readStream.destroy() return } - - try { - let audio = Buffer.concat([meta.header, chunk]); - - const offlineCtx = await setupCtx(audio, undefined, 'model'); - let worker; - if (offlineCtx) { - offlineCtx.startRendering().then((resampled) => { - const myArray = resampled.getChannelData(0); - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - feedChunksToModel(myArray, chunkStart, file, end, worker); - chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; - }).catch((error) => { - console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); - const fileIndex = filesBeingProcessed.indexOf(file); - if (fileIndex !== -1) { - canBeRemovedFromCache.push(filesBeingProcessed.splice(fileIndex, 1)) - } - // Note: The promise should reject when startRendering is called a second time on an OfflineAudioContext - }); - } else { - console.log('Short chunk', chunk.length, 'padding') - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - - // Create array with 0's (short segment of silence that will trigger the finalChunk flag - const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); - feedChunksToModel(myArray, chunkStart, file, end); - //readStream.resume(); - } - } catch (error) { - console.warn(file, error) - //trackError(error.message, 'getWavePredictBuffers', STATE.batchSize); - //UI.postMessage({event: 'generate-alert', message: "Chirpity is struggling to handle the high number of prediction requests. You should increasse the batch size in settings to compensate for this, or risk skipping audio sections. Press Esc to abort."}) - } + const audio = Buffer.concat([meta.header, chunk]); + predictQueue.push([audio, file, end, chunkStart]); + chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate + processPredictQueue(); }) - // readStream.on('end', function () { - // //readStream.close(); - // DEBUG && console.log('All chunks sent for ', file) - // }) + readStream.on('error', err => { console.log(`readstream error: ${err}, start: ${start}, , end: ${end}, duration: ${metadata[file].duration}`); err.code === 'ENOENT' && notifyMissingFile(file); }) }) } - + +async function processPredictQueue(){ + if (processing || predictQueue.length === 0) return; // Exit if already processing or queue is empty + processing = true; // Set processing flag to true + + const [audio, file, end, chunkStart] = predictQueue.shift(); // Dequeue chunk + await setupCtx(audio, undefined, 'model').then(offlineCtx => { + let worker; + if (offlineCtx) { + offlineCtx.startRendering().then((resampled) => { + const myArray = resampled.getChannelData(0); + workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; + worker = workerInstance; + console.log('ChunkStart', chunkStart, 'array length', myArray.length, 'file', file); + feedChunksToModel(myArray, chunkStart, file, end, worker); + //chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; + processing = false; // Reset processing flag + processPredictQueue(); // Process next chunk in the queue + }).catch((error) => { + console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); + const fileIndex = filesBeingProcessed.indexOf(file); + processing = false; // Reset processing flag + processPredictQueue(); // Process next chunk in the queue + }); + } else { + console.log('Short chunk', chunk.length, 'padding'); + workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; + worker = workerInstance; + const myArray = new Float32Array(Array.from({ length: chunkLength }).fill(0)); + feedChunksToModel(myArray, chunkStart, file, end); + }}).catch(error => { + console.warn(file, error); + }) +} + const getPredictBuffers = async ({ file = '', start = 0, end = undefined }) => { @@ -1279,10 +1280,11 @@ const getPredictBuffers = async ({ return } if (chunk === null || chunk.byteLength <= 1) { - // deal with part-full buffers - if (concatenatedBuffer.length){ - const audio = Buffer.concat([WAV_HEADER, concatenatedBuffer]); - await sendBuffer(audio, chunkStart, chunkLength, end, file) + // EOF: deal with part-full buffers + if (concatenatedBuffer.length){ + const audio = Buffer.concat([WAV_HEADER, concatenatedBuffer]); + predictQueue.push([audio, file, end, chunkStart]); + processPredictQueue(); } DEBUG && console.log('All chunks sent for ', file); //command.kill(); @@ -1294,9 +1296,6 @@ const getPredictBuffers = async ({ concatenatedBuffer = Buffer.concat(bufferList); } catch (error) { console.warn(error) - //trackError(error.message, 'getPredictBuffers', STATE.batchSize); - //UI.postMessage({event: 'generate-alert', message: "Chirpity is struggling to handle the high number of prediction requests. You should increasse the batch size in settings to compensate for this, or risk skipping audio sections. Press Esc to abort."}) - } // if we have a full buffer @@ -1304,34 +1303,9 @@ const getPredictBuffers = async ({ chunk = concatenatedBuffer.subarray(0, highWaterMark); concatenatedBuffer = concatenatedBuffer.subarray(highWaterMark); const audio = Buffer.concat([WAV_HEADER, chunk]) - const offlineCtx = await setupCtx(audio, undefined, 'model').catch( (error) => console.warn(error)); - let worker; - if (offlineCtx) { - offlineCtx.startRendering().then((resampled) => { - const myArray = resampled.getChannelData(0); - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - DEBUG && console.log('chunkstart:', chunkStart, 'file', file) - feedChunksToModel(myArray, chunkStart, file, end, worker); - chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; - }).catch((error) => { - console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); - const fileIndex = filesBeingProcessed.indexOf(file); - if (fileIndex !== -1) { - filesBeingProcessed.splice(fileIndex, 1) - } - }); - } else { - console.warn('Short chunk', chunk.length, 'padding') - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - - // Create array with 0's (short segment of silence that will trigger the finalChunk flag - const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); - feedChunksToModel(myArray, chunkStart, file, end); - console.log('chunkstart:', chunkStart, 'file', file) - - } + predictQueue.push([audio, file, end, chunkStart]); + chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate + processPredictQueue(); } } }); @@ -1355,37 +1329,6 @@ const getPredictBuffers = async ({ }).catch(error => console.log(error)); } -async function sendBuffer(audio, chunkStart, chunkLength, end, file){ - const offlineCtx = await setupCtx(audio, undefined, 'model'); - let worker; - if (offlineCtx) { - offlineCtx.startRendering().then((resampled) => { - const myArray = resampled.getChannelData(0); - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - feedChunksToModel(myArray, chunkStart, file, end, worker); - //chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; - // Now the async stuff is done ==> - //readStream.resume(); - }).catch((error) => { - console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); - const fileIndex = filesBeingProcessed.indexOf(file); - if (fileIndex !== -1) { - filesBeingProcessed.splice(fileIndex, 1) - } - }); - } else { - if (audio.length){ - console.warn('Short chunk', audio.length, 'padding') - workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; - worker = workerInstance; - - // Create array with 0's (short segment of silence that will trigger the finalChunk flag - const myArray = new Float32Array(Array.from({length: chunkLength}).fill(0)); - feedChunksToModel(myArray, chunkStart, file, end); - } - } -} /** * Called when file first loaded, when result clicked and when saving or sending file snippets @@ -2145,7 +2088,6 @@ function updateFilesBeingProcessed(file) { // This method to determine batch complete const fileIndex = filesBeingProcessed.indexOf(file); if (fileIndex !== -1) { - // canBeRemovedFromCache.push(filesBeingProcessed.splice(fileIndex, 1)) filesBeingProcessed.splice(fileIndex, 1) UI.postMessage({ event: 'progress', progress: 1, file: file }) } From 02f9fd3882b4cb9bdc4f2b278eb9bd1a83b6f1cc Mon Sep 17 00:00:00 2001 From: mattk70 Date: Fri, 22 Mar 2024 09:57:53 +0000 Subject: [PATCH 4/8] Changes: UI.js change result header to grey while analysing, remove event handlers & pointer Disable purge file when not in archive/explore mode removed register global shortcut for mac q and put in global actions fixed gap after switching from explore to analyse removed function getselectionRange changed no xccache warnings to log on debug *working on* loading overlay for media load of comparison calls package.json - bumped version tracking.js - squash result indices worker.js: modified getTotal to acccept a file qualiifier, and return [total,offset,positon] fixes prepResultsStatement so it only returns topRankin species per datetime * working on bandpass filter* wait for getSummary to complete before posting analysis-complete in parseMessage --- Help/usage.html | 27 ++++----- css/style.css | 2 - index.html | 6 +- js/tracking.js | 2 + js/ui.js | 121 +++++++++++++++++++++++++++-------------- js/worker.js | 142 +++++++++++++++++++++++++----------------------- main.js | 5 +- package.json | 2 +- preload.js | 3 +- 9 files changed, 177 insertions(+), 133 deletions(-) diff --git a/Help/usage.html b/Help/usage.html index 91d0d786..5a88e47c 100644 --- a/Help/usage.html +++ b/Help/usage.html @@ -8,12 +8,7 @@

Suggested Workflow

-
- The context menu accessed from the filename contains options to amend the file's start time and the recording location -
File name context menu.
-
+
  1. Open an audio file, files or folder of audio. There are a three ways to do this:
      @@ -28,7 +23,12 @@

      Suggested Workflow

      detected. If not, you can still display (and store) the correct time of day for the file and detections if you edit its start time. This can be edited by right-clicking the filename in the control bar. - +
      + The context menu accessed from the filename contains options to amend the file's start time and the recording location +
      File name context menu.
      +
    1. Select "Analyse file" from the @@ -46,17 +46,18 @@
      Exploring Detections
      the table will cause the playhead to jump to the start of that detection and highlight its location on the spectrogram. Right-clicking the row will bring up the detection context menu. -
      - The context menu accessed from a detection contains options to inspect and amend the record -
      Detection context menu.
      -
      + This contains options specific to the currently highlighted detection. You may press the SpaceBar to play from the beginning of the selection and continue playing the audio until you press the SpaceBar again. Clicking the spectrogram will move the playhead to that position. If currently playing, playback will continue, so this allows you to replay a call repeatedly.
    2. +
      + The context menu accessed from a detection contains options to inspect and amend the record +
      Detection context menu.
      +
    3. If multiple detections apply for a given time period, a circle will be displayed next to the Species' name. Clicking this circle will display the other species detected in the region, and the confidence given to diff --git a/css/style.css b/css/style.css index 736bd46c..0b612060 100644 --- a/css/style.css +++ b/css/style.css @@ -212,8 +212,6 @@ tbody tr:not(.table-active):hover { #resultSummary>th { position: sticky; top: 0; - color: white; - background-color: #1b1e21; z-index: 2; } diff --git a/index.html b/index.html index 16b474d4..fb6a630c 100644 --- a/index.html +++ b/index.html @@ -31,7 +31,7 @@
      -
      Loading Xento Canto data...
      +
      Loading Xento Canto data...
      Loading... @@ -620,7 +620,7 @@
      Saved Records
      explore Explore Archive
    4. - @@ -1041,7 +1041,7 @@
      - + diff --git a/js/tracking.js b/js/tracking.js index 0130b02b..c67ea88f 100644 --- a/js/tracking.js +++ b/js/tracking.js @@ -4,6 +4,8 @@ const ID_SITE = 3; function trackEvent(uuid, event, action, name, value){ DEBUG && event === 'Error' && console.log(action, name); + // Squash result numbers + name = typeof name == 'string' ? name.replace(/result\d+/, 'result') : name; const t = new Date() name = name ? `&e_n=${name}` : ''; value = value ? `&e_v=${value}` : ''; diff --git a/js/ui.js b/js/ui.js index 3e6de4b8..8f70fce7 100644 --- a/js/ui.js +++ b/js/ui.js @@ -863,12 +863,10 @@ function resetDiagnostics() { // Worker listeners function analyseReset() { DOM.fileNumber.textContent = ''; - PREDICTING = true; + if (STATE.mode === 'analyse') PREDICTING = true; resetDiagnostics(); AUDACITY_LABELS = {}; DOM.progressDiv.classList.remove('d-none'); - // DIAGNOSTICS - t0_analysis = Date.now(); } function isEmptyObject(obj) { @@ -908,11 +906,11 @@ const getSelectionResults = (fromDB) => { }); } -const analyseLink = document.getElementById('analyse'); -const analyseAllLink = document.getElementById('analyseAll'); function postAnalyseMessage(args) { if (!PREDICTING) { + // Start a timer + t0_analysis = Date.now(); disableMenuItem(['analyseSelection']); const selection = !!args.end; const filesInScope = args.filesInScope; @@ -921,6 +919,9 @@ function postAnalyseMessage(args) { analyseReset(); refreshResultsView(); resetResults({clearSummary: true, clearPagination: true, clearResults: true}); + // change result header to indicate deactivation + DOM.resultHeader.classList.add('text-bg-secondary'); + DOM.resultHeader.classList.remove('text-bg-dark'); } worker.postMessage({ action: 'analyse', @@ -1668,9 +1669,24 @@ const setUpWorkerMessaging = () => { case "model-ready": {onModelReady(args); break; } - case "mode-changed": {STATE.mode = args.mode; + case "mode-changed": { + const mode = args.mode; + STATE.mode = mode; renderFilenamePanel(); - config.debug && console.log("Mode changed to: " + args.mode); + config.debug && console.log("Mode changed to: " + mode); + if (mode === 'archive' || mode === 'explore') { + enableMenuItem(['purge-file']); + // change header to indicate activation + DOM.resultHeader.classList.remove('text-bg-secondary'); + DOM.resultHeader.classList.add('text-bg-dark'); + // PREDICTING = false; + // STATE.analysisDone = true; + } else { + disableMenuItem(['purge-file']); + // change header to indicate deactivation + DOM.resultHeader.classList.add('text-bg-secondary'); + DOM.resultHeader.classList.remove('text-bg-dark'); + } break; } case "summary-complate": {onSummaryComplete(args); @@ -1685,8 +1701,8 @@ 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'); + //PREDICTING = false; + //DOM.progressDiv.classList.add('d-none'); break; } case 'ready-for-tour':{ @@ -2291,7 +2307,6 @@ function onChartData(args) { }) const loadModel = () => { - if (PREDICTING) t0_warmup = Date.now(); worker.postMessage({ action: 'load-model', @@ -2381,8 +2396,8 @@ function onChartData(args) { KeyA: async function (e) { if ( e.ctrlKey || e.metaKey) { if (currentFile) { - if (e.shiftKey) analyseAllLink.click(); - else analyseLink.click() + if (e.shiftKey) document.getElementById('analyseAll').click(); + else document.getElementById('analyse').click() } } }, @@ -2423,6 +2438,9 @@ function onChartData(args) { KeyP: function () { (typeof region !== 'undefined') ? region.play() : console.log('Region undefined') }, + KeyQ: function (e) { + e.metaKey && isMac && window.electron.exitApplication() + }, KeyS: function (e) { if ( e.ctrlKey || e.metaKey) { worker.postMessage({ action: 'save2db', file: currentFile}); @@ -2816,7 +2834,7 @@ function onChartData(args) { summaryHTML += ` @@ -2863,7 +2881,7 @@ function onChartData(args) { // Select the first row activeRow = table.querySelector('tr:first-child'); activeRow?.classList.add('table-active'); - document.getElementById('resultsDiv').scrollTo({ top: 0, left: 0, behavior: "smooth" }); + //document.getElementById('resultsDiv').scrollTo({ top: 0, left: 0, behavior: "smooth" }); } } @@ -2905,7 +2923,8 @@ function onChartData(args) { trackEvent(config.UUID, `${config.model}-${config.backend}`, 'Audio Duration', config.backend, Math.round(DIAGNOSTICS['Audio Duration'])); trackEvent(config.UUID, `${config.model}-${config.backend}`, 'Analysis Duration', config.backend, parseInt(analysisTime)); trackEvent(config.UUID, `${config.model}-${config.backend}`, 'Analysis Rate', config.backend, parseInt(rate)); - generateToast({ message:'Analysis complete.'}) + STATE.selection || generateToast({ message:'Analysis complete.'}) + activateResultFilters(); } /* @@ -2918,6 +2937,7 @@ function onChartData(args) { active = undefined, }) { updateSummary({ summary: summary, filterSpecies: filterSpecies }); + if (! PREDICTING || STATE.mode !== 'analyse') activateResultFilters(); // Why do we do audacity labels here? AUDACITY_LABELS = audacityLabels; if (! isEmptyObject(AUDACITY_LABELS)) { @@ -2986,7 +3006,7 @@ function onChartData(args) { // summary.addEventListener('click', speciesFilter); function speciesFilter(e) { - if (['TBODY', 'TH', 'DIV'].includes(e.target.tagName)) return; // on Drag or clicked header + if (PREDICTING || ['TBODY', 'TH', 'DIV'].includes(e.target.tagName)) return; // on Drag or clicked header clearActive(); let species, range; // Am I trying to unfilter? @@ -2995,7 +3015,7 @@ function onChartData(args) { } else { //Clear any highlighted rows const tableRows = summary.querySelectorAll('tr'); - tableRows.forEach(row => {row.classList.remove('text-warning');}) + tableRows.forEach(row => row.classList.remove('text-warning')) // Add a highlight to the current row e.target.closest('tr').classList.add('text-warning'); // Clicked on unfiltered species @@ -3025,7 +3045,7 @@ function onChartData(args) { isFromDB = false, selection = false }) { - + let tr = ''; if (typeof (result) === 'string') { // const nocturnal = config.detect.nocmig ? 'during the night' : ''; @@ -3038,18 +3058,20 @@ function onChartData(args) { selectionTable.textContent = ''; } else { + adjustSpecDims(true); + if (isFromDB) PREDICTING = false; DOM.resultHeader.innerHTML =` - - - - + + + + `; setTimelinePreferences(); - showSortIcon(); + //showSortIcon(); showElement(['resultTableContainer', 'resultsHead'], false); } } else if (!isFromDB && index % (config.limit + 1) === 0) { @@ -3243,12 +3265,6 @@ function onChartData(args) { newRow?.click(); } - const getSelectionRange = () => { - return STATE.selection ? - { start: (STATE.selection.start * 1000) + fileStart, end: (STATE.selection.end * 1000) + fileStart } : - undefined - } - function sendFile(mode, result) { let start, end, filename; if (result) { @@ -3579,7 +3595,7 @@ function onChartData(args) { - function showSortIcon() { + function activateResultFilters() { const timeHeadings = document.getElementsByClassName('time-sort-icon'); const speciesHeadings = document.getElementsByClassName('species-sort-icon'); @@ -3587,10 +3603,12 @@ function onChartData(args) { [...timeHeadings].forEach(heading => { heading.classList.toggle('d-none', sortOrderScore); + heading.parentNode.classList.add('pointer'); }); [...speciesHeadings].forEach(heading => { heading.classList.toggle('d-none', !sortOrderScore); + heading.parentNode.classList.add('pointer'); if (sortOrderScore && STATE.sortOrder.includes('ASC')){ // Flip the sort icon heading.classList.add('flipped') @@ -3598,6 +3616,14 @@ function onChartData(args) { heading.classList.remove('flipped') } }); + // Add pointer icon to species summaries + const summarySpecies = document.getElementById('summary').querySelectorAll('.cname'); + summarySpecies.forEach(row => row.classList.add('pointer')) + // change results header to indicate activation + DOM.resultHeader.classList.remove('text-bg-secondary'); + DOM.resultHeader.classList.add('text-bg-dark'); + // Add a hover to summary to indicate activation + document.getElementById('resultSummary').classList.add('table-hover'); } const setSortOrder = (order) => { @@ -4076,12 +4102,23 @@ DOM.gain.addEventListener('input', () => { case 'usage': { (async () => await populateHelpModal('Help/usage.html', 'Usage Guide'))(); break } case 'bugs': { (async () => await populateHelpModal('Help/bugs.html', 'Issues, queries or bugs'))(); break } case 'species': { worker.postMessage({action: 'get-valid-species', file: currentFile}); break } + case 'startTour': { prepTour(); break } case 'eBird': { (async () => await populateHelpModal('Help/ebird.html', 'eBird Record FAQ'))(); break } + case 'export-list': { exportSpeciesList(); break } + case 'sort-position': + case 'sort-time': { + if (! PREDICTING){ + setSortOrder('timestamp') + } + break; + } case 'confidence-sort': { - const sortBy = STATE.sortOrder === 'score DESC ' ? 'score ASC ' : 'score DESC '; - setSortOrder(sortBy); + if (! PREDICTING){ + const sortBy = STATE.sortOrder === 'score DESC ' ? 'score ASC ' : 'score DESC '; + setSortOrder(sortBy); + } break; } case 'speciesFilter': { speciesFilter(e); break} @@ -4095,11 +4132,6 @@ DOM.gain.addEventListener('input', () => { case 'apply-location': {setDefaultLocation(); break } case 'cancel-location': {cancelDefaultLocation(); break } - case 'sort-position': - case 'sort-time': { - setSortOrder('timestamp') - break; - } case 'zoomIn': case 'zoomOut': { zoomSpec(e) @@ -4116,7 +4148,7 @@ DOM.gain.addEventListener('input', () => { } case 'clear-call-cache': { const data = fs.rm(p.join(appPath, 'XCcache.json'), err =>{ - if (err) generateToast({message: 'No call cache was found.'}) && console.warn('No XC cache found', err); + if (err) generateToast({message: 'No call cache was found.'}) && config.debug && console.log('No XC cache found', err); else generateToast({message: 'The call cache was successfully cleared.'}) }) break; @@ -4697,7 +4729,7 @@ function setListUIState(list){ startTour(); } - document.getElementById('startTour').addEventListener('click', prepTour); + // Function to display update download progress const tracking = document.getElementById('update-progress'); @@ -4854,7 +4886,7 @@ async function getXCComparisons(){ const data = await fs.promises.readFile(p.join(appPath, 'XCcache.json'), 'utf8'); XCcache = JSON.parse(data); } catch (err) { - console.warn('No XC cache found', err); + config.debug && console.log('No XC cache found', err); XCcache = {}; // Set XCcache as an empty object } @@ -5089,7 +5121,12 @@ function showCompareSpec() { if (ws) ws.destroy() const activeCarouselItem = document.querySelector('#recordings .tab-pane.active .carousel-item.active'); const mediaContainer = activeCarouselItem.lastChild; - console.log("id is ", mediaContainer.id) + // need to prevent accumulation, and find event for show/hide loading + let loading = document.getElementById('loadingOverlay') + loading = loading.cloneNode(true); + loading.classList.remove('d-none', 'text-white'); + mediaContainer.appendChild(loading); + //console.log("id is ", mediaContainer.id) const [specContainer, file] = mediaContainer.getAttribute('name').split('|'); // Create an instance of WaveSurfer const audioCtx = new AudioContext({ latencyHint: 'interactive', sampleRate: sampleRate }); @@ -5135,6 +5172,8 @@ function showCompareSpec() { // prevent listener accumulation playButton.removeEventListener('click', playComparison) playButton.addEventListener('click', playComparison) + ws.on('load', loading.classList.remove('d-none')) + ws.on('canplay', loading.classList.add('d-none')) } diff --git a/js/worker.js b/js/worker.js index 458fa04c..e227e550 100644 --- a/js/worker.js +++ b/js/worker.js @@ -324,7 +324,8 @@ async function handleMessage(e) { args.updateSummary && await getSummary(args); const t2 = Date.now(); args.included = await getIncludedIDs(args.file); - await getTotal(args); + 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; @@ -583,7 +584,7 @@ const prepSummaryStatement = (included) => { } -const getTotal = async ({species = undefined, offset = undefined, included = []}) => { +const getTotal = async ({species = undefined, offset = undefined, included = [], file = undefined}= {}) => { let params = []; const range = STATE.mode === 'explore' ? STATE.explore.range : undefined; offset = offset ?? (species !== undefined ? STATE.filteredOffset[species] : STATE.globalOffset); @@ -594,7 +595,10 @@ const getTotal = async ({species = undefined, offset = undefined, included = []} FROM records JOIN files ON records.fileID = files.id WHERE confidence >= ${STATE.detect.confidence} `; - + if (file) { + params.push(file) + SQL += ' AND files.name = ? ' + } if (species) { params.push(species); SQL += ' AND speciesID = (SELECT id from species WHERE cname = ?) '; @@ -616,7 +620,7 @@ const getTotal = async ({species = undefined, offset = undefined, included = []} SQL += `SELECT COUNT(confidence) AS total FROM MaxConfidencePerDateTime WHERE rank <= ${STATE.topRankin}`; const {total} = await STATE.db.getAsync(SQL, ...params) - UI.postMessage({event: 'total-records', total: total, offset: offset, species: species}) + return [total, offset, species] } const prepResultsStatement = (species, noLimit, included, offset, topRankin) => { @@ -663,10 +667,7 @@ const prepResultsStatement = (species, noLimit, included, offset, topRankin) => if (useRange) { resultStatement += ` AND dateTime BETWEEN ${range.start} AND ${range.end} `; } - if (species){ - resultStatement+= ` AND cname = ? `; - params.push(species); - } + else if (filtersApplied(included)) { resultStatement += ` AND speciesID IN (${prepParams(included)}) `; params.push(...included); @@ -705,7 +706,10 @@ const prepResultsStatement = (species, noLimit, included, offset, topRankin) => ranked_records WHERE rank <= ? `; params.push(topRankin); - + if (species){ + resultStatement+= ` AND cname = ? `; + params.push(species); + } const limitClause = noLimit ? '' : 'LIMIT ? OFFSET ?'; noLimit || params.push(STATE.limit, offset); @@ -1055,6 +1059,17 @@ async function setupCtx(audio, rate, destination) { previousFilter ? previousFilter.connect(lowshelfFilter) : offlineSource.connect(lowshelfFilter); previousFilter = lowshelfFilter; } + const from = 2000; + const to = 8000; + const geometricMean = Math.sqrt(from * to); + + const bandpassFilter = offlineCtx.createBiquadFilter(); + bandpassFilter.type = 'bandpass'; + bandpassFilter.frequency.value = geometricMean; + bandpassFilter.Q.value = geometricMean / (to - from); + bandpassFilter.channelCount = 1; + previousFilter ? previousFilter.connect(bandpassFilter) : offlineSource.connect(bandpassFilter); + previousFilter = bandpassFilter; } } if (STATE.audio.gain){ @@ -1068,7 +1083,7 @@ async function setupCtx(audio, rate, destination) { offlineSource.start(); return offlineCtx; } ) - .catch( (error) => console.log(error)); + .catch( (error) => console.warn(error)); // // Create a compressor node @@ -1204,14 +1219,13 @@ async function processPredictQueue(){ const myArray = resampled.getChannelData(0); workerInstance = ++workerInstance >= NUM_WORKERS ? 0 : workerInstance; worker = workerInstance; - console.log('ChunkStart', chunkStart, 'array length', myArray.length, 'file', file); feedChunksToModel(myArray, chunkStart, file, end, worker); //chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate; processing = false; // Reset processing flag processPredictQueue(); // Process next chunk in the queue }).catch((error) => { console.error(`PredictBuffer rendering failed: ${error}, file ${file}`); - const fileIndex = filesBeingProcessed.indexOf(file); + updateFilesBeingProcessed(file); processing = false; // Reset processing flag processPredictQueue(); // Process next chunk in the queue }); @@ -1340,6 +1354,7 @@ const fetchAudioBuffer = async ({ }) => { //if (end - start < 0.1) return // prevents dataset creation barfing with v. short buffer const stream = new PassThrough(); + const data = []; // Use ffmpeg to extract the specified audio segment return new Promise((resolve, reject) => { let command = ffmpeg(file) @@ -1350,6 +1365,7 @@ const fetchAudioBuffer = async ({ .audioFrequency(24_000) // Set sample rate to 24000 Hz (always - this is for wavesurfer) .output(stream, { end:true }); if (STATE.audio.normalise) command = command.audioFilter("loudnorm=I=-16:LRA=11:TP=-1.5"); + command.on('error', error => { updateFilesBeingProcessed(file) reject(new Error('Error extracting audio segment:', error)); @@ -1358,24 +1374,17 @@ const fetchAudioBuffer = async ({ DEBUG && console.log('FFmpeg command: ' + commandLine); }) - command.on('end', () => { - // End the stream to signify completion - stream.end(); - }); - - const data = []; stream.on('data', chunk => { - if (chunk.byteLength > 1) data.push(chunk); + chunk.byteLength > 1 && data.push(chunk); }); stream.on('end', async () => { - if (data.length === 0) return + if (data.length === 0) return; //Add the audio header data.unshift(CHIRPITY_HEADER) // Concatenate the data chunks into a single Buffer const audio = Buffer.concat(data); - - // Navtive CHIRPITY_HEADER (24kHz) here for UI + // Native CHIRPITY_HEADER (24kHz) here for UI const offlineCtx = await setupCtx(audio, sampleRate, 'UI').catch( (error) => {console.error(error.message)}); if (offlineCtx){ offlineCtx.startRendering().then(resampled => { @@ -1385,7 +1394,6 @@ const fetchAudioBuffer = async ({ resolve(resampled); }).catch((error) => { console.error(`FetchAudio rendering failed: ${error}`); - // Note: The promise should reject when startRendering is called a second time on an OfflineAudioContext }); } }); @@ -1966,43 +1974,43 @@ const parsePredictions = async (response) => { if (! STATE.selection) await generateInsertQuery(latestResult, file).catch( (error) => console.log('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; - } + 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.log('Error getting species name', error)); - const result = { - timestamp: timestamp, - position: key, - end: end, - file: file, - cname: cname, - sname: sname, - score: confidence - } + const { cname, sname } = await memoryDB.getAsync(`SELECT cname, sname FROM species WHERE id = ${speciesID}`).catch( (error) => console.log('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; - }; + // Only show the highest confidence detection, unless it's a selection analysis + if (! STATE.selection) break; + }; } } } @@ -2016,7 +2024,7 @@ const parsePredictions = async (response) => { if (fileProgress === 1) { if (index === 0 ) { const result = `No detections found in ${file}. Searched for records using the ${STATE.list} list and having a minimum confidence of ${STATE.detect.confidence/10}%` - UI.postMessage({ + UI.postMessage({ event: 'new-result', file: file, result: result, @@ -2062,14 +2070,11 @@ async function parseMessage(e) { const remaining = predictionsReceived[response.file] - predictionsRequested[response.file] if (remaining === 0) { if (filesBeingProcessed.length) { - processNextFile({ - worker: worker - }); + processNextFile({ worker: worker }); } else if ( !STATE.selection) { - getSummary(); - UI.postMessage({ - event: "analysis-complete" - }); + getSummary().then(() => UI.postMessage({event: "analysis-complete"})); + } else { + UI.postMessage({event: "analysis-complete"}); } } } @@ -2337,7 +2342,8 @@ const getResults = async ({ const position = await getPosition({species: species, dateTime: select.dateTime, included: included}); offset = Math.floor(position/limit) * limit; // update the pagination - await getTotal({species: species, offset: offset, included: included}) + const [total, offset, species] = await getTotal({species: species, offset: offset, included: included}) + UI.postMessage({event: 'total-records', total: total, offset: offset, species: species}) } offset = offset ?? (species ? (STATE.filteredOffset[species] ?? 0) : STATE.globalOffset); if (species) STATE.filteredOffset[species] = offset; @@ -2425,7 +2431,7 @@ const getResults = async ({ } else { sendResult(++index, r, true) } - if (i === result.length -1) UI.postMessage({event: 'processing-complete'}) + if (i === result.length -1) UI.postMessage({event: 'processing-complete'}) } if (!result.length) { if (STATE.selection) { diff --git a/main.js b/main.js index 79ad1c6d..42c95840 100644 --- a/main.js +++ b/main.js @@ -328,6 +328,7 @@ app.whenReady().then(async () => { ipcMain.handle('getVersion', () => app.getVersion()); ipcMain.handle('isMac', () => process.platform === 'darwin'); ipcMain.handle('getAudio', () => path.join(__dirname.replace('app.asar', ''), 'Help', 'example.mp3')); + ipcMain.handle('exitApplication', () => app.quit()); // Debug mode try { @@ -351,10 +352,6 @@ app.whenReady().then(async () => { //const appIcon = new Tray('./img/icon/icon.png') app.dock.setIcon(__dirname + '/img/icon/icon.png'); app.dock.bounce(); - // Close app on Command+Q on OSX - globalShortcut.register('Command+Q', () => { - if (mainWindow.isFocused())app.quit(); - }) } else { // Quit when all windows are closed. app.setAppUserModelId('chirpity') diff --git a/package.json b/package.json index 2caa578f..cd8156c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chirpity", - "version": "1.6.0", + "version": "1.6.1", "description": "Chirpity Nocmig", "main": "main.js", "scripts": { diff --git a/preload.js b/preload.js index 4d7d13f7..e3953102 100644 --- a/preload.js +++ b/preload.js @@ -52,7 +52,8 @@ contextBridge.exposeInMainWorld('electron', { getTemp: () => ipcRenderer.invoke('getTemp'), getVersion: () => ipcRenderer.invoke('getVersion'), getAudio: () => ipcRenderer.invoke('getAudio'), - isMac: () => ipcRenderer.invoke('isMac') + isMac: () => ipcRenderer.invoke('isMac'), + exitApplication: () => ipcRenderer.invoke('exitApplication') }); contextBridge.exposeInMainWorld('module', { From 0473fc62641787a8e5740490355654dbb644bdc6 Mon Sep 17 00:00:00 2001 From: Mattk70 Date: Fri, 22 Mar 2024 10:32:26 +0000 Subject: [PATCH 5/8] fixed linting warnings --- js/ui.js | 2 +- js/worker.js | 173 ++++++++++++++++++++++++++------------------------- 2 files changed, 88 insertions(+), 87 deletions(-) diff --git a/js/ui.js b/js/ui.js index 8f70fce7..f2057454 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1857,7 +1857,7 @@ document.addEventListener('change', function (e) { }) // Save audio clip -function onSaveAudio({file: file, filename: filename}){ +function onSaveAudio({file, filename}){ const anchor = document.createElement('a'); document.body.appendChild(anchor); anchor.style = 'display: none'; diff --git a/js/worker.js b/js/worker.js index e227e550..b44e3cf2 100644 --- a/js/worker.js +++ b/js/worker.js @@ -317,96 +317,97 @@ async function handleMessage(e) { metadata[args.file]?.isSaved ? await onChangeMode("archive") : await onChangeMode("analyse"); break; } - 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"); + 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; } + case "get-detected-species-list": {getDetectedSpecies(); + break; + } + case "get-valid-species": {getValidSpecies(args.file); + break; + } + case "get-locations": { getLocations({ db: STATE.db, file: args.file }); break; - } - case "get-detected-species-list": {getDetectedSpecies(); - break; - } - case "get-valid-species": {getValidSpecies(args.file); + } + case "get-valid-files-list": { await getFiles(args.files); + break; + } + case "insert-manual-record": { await onInsertManualRecord(args); + break; + } + case "load-model": { + if (filesBeingProcessed.length) onAbort(args); + else predictWorkers.length && terminateWorkers(); + await onLaunch(args); + break; + } + case "post": {await uploadOpus(args); + break; + } + case "purge-file": {onFileDelete(args.fileName); + break; + } + case "save": {DEBUG && console.log("file save requested"); + await saveAudio(args.file, args.start, args.end, args.filename, args.metadata); break; - } - case "get-locations": { getLocations({ db: STATE.db, file: args.file }); - break; -} -case "get-valid-files-list": { await getFiles(args.files); - break; -} -case "insert-manual-record": { await onInsertManualRecord(args); - break; -} -case "load-model": { - if (filesBeingProcessed.length) onAbort(args); - else predictWorkers.length && terminateWorkers(); - await onLaunch(args); - break; -} -case "post": {await uploadOpus(args); - break; -} -case "purge-file": {onFileDelete(args.fileName); - break; -} -case "save": {DEBUG && console.log("file save requested"); -await saveAudio(args.file, args.start, args.end, args.filename, args.metadata); -break; -} -case "save2db": {await onSave2DiskDB(args); - break; -} -case "set-custom-file-location": {onSetCustomLocation(args); - break; -} -case "update-buffer": {await loadAudioFile(args); - break; -} -case "update-file-start": {await onUpdateFileStart(args); - break; -} -case "update-list": { - STATE.list = args.list; - STATE.customList = args.list === 'custom' ? args.customList : STATE.customList; - const {lat, lon, week} = STATE; - // Clear the LIST_CACHE & STATE.included kesy to force list regeneration - LIST_CACHE = {}; //[`${lat}-${lon}-${week}-${STATE.model}-${STATE.list}`]; - delete STATE.included?.[STATE.model]?.[STATE.list]; - LIST_WORKER && await setIncludedIDs(lat, lon, week ) - args.refreshResults && await Promise.all([getResults(), getSummary()]); - break; -} -case 'update-locale': { + } + case "save2db": {await onSave2DiskDB(args); + break; + } + case "set-custom-file-location": {onSetCustomLocation(args); + break; + } + case "update-buffer": {await loadAudioFile(args); + break; + } + case "update-file-start": {await onUpdateFileStart(args); + break; + } + case "update-list": { + STATE.list = args.list; + STATE.customList = args.list === 'custom' ? args.customList : STATE.customList; + const {lat, lon, week} = STATE; + // Clear the LIST_CACHE & STATE.included kesy to force list regeneration + LIST_CACHE = {}; //[`${lat}-${lon}-${week}-${STATE.model}-${STATE.list}`]; + delete STATE.included?.[STATE.model]?.[STATE.list]; + LIST_WORKER && await setIncludedIDs(lat, lon, week ) + args.refreshResults && await Promise.all([getResults(), getSummary()]); + break; + } + case 'update-locale': { - await onUpdateLocale(args.locale, args.labels, args.refreshResults) - break; -} -case "update-state": { - TEMP = args.temp || TEMP; - appPath = args.path || appPath; - // If we change the speciesThreshold, we need to invalidate any location caches - if (args.speciesThreshold) { - if (STATE.included?.['birdnet']?.['location']) STATE.included.birdnet.location = {}; - if (STATE.included?.['chirpity']?.['location']) STATE.included.chirpity.location = {}; - } - // likewise, if we change the "use local birds" setting we need to flush the migrants cache" - if (args.local !== undefined){ - if (STATE.included?.['birdnet']?.['nocturnal']) delete STATE.included.birdnet.nocturnal; + await onUpdateLocale(args.locale, args.labels, args.refreshResults) + break; + } + case "update-state": { + TEMP = args.temp || TEMP; + appPath = args.path || appPath; + // If we change the speciesThreshold, we need to invalidate any location caches + if (args.speciesThreshold) { + if (STATE.included?.['birdnet']?.['location']) STATE.included.birdnet.location = {}; + if (STATE.included?.['chirpity']?.['location']) STATE.included.chirpity.location = {}; + } + // likewise, if we change the "use local birds" setting we need to flush the migrants cache" + if (args.local !== undefined){ + if (STATE.included?.['birdnet']?.['nocturnal']) delete STATE.included.birdnet.nocturnal; + } + STATE.update(args); + break; + } + default: {UI.postMessage("Worker communication lines open"); + } } - STATE.update(args); - break; -} -default: {UI.postMessage("Worker communication lines open"); -} -} } ipcRenderer.on('new-client', (event) => { @@ -2342,7 +2343,7 @@ const getResults = async ({ const position = await getPosition({species: species, dateTime: select.dateTime, included: included}); offset = Math.floor(position/limit) * limit; // update the pagination - const [total, offset, species] = await getTotal({species: species, offset: offset, included: included}) + const [total, , species] = await getTotal({species: species, offset: offset, included: included}) UI.postMessage({event: 'total-records', total: total, offset: offset, species: species}) } offset = offset ?? (species ? (STATE.filteredOffset[species] ?? 0) : STATE.globalOffset); From 5c0e0a811d48091ce68d55733188e0166c1e7e6c Mon Sep 17 00:00:00 2001 From: Mattk70 Date: Fri, 22 Mar 2024 14:47:12 +0000 Subject: [PATCH 6/8] ameneded comparison loading text to indicate it is the audio loading Fixed filtered species pagination by moving the species qualifier to the outer where clause --- js/ui.js | 10 ++++++++-- js/worker.js | 31 ++++++++++++++++--------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/js/ui.js b/js/ui.js index f2057454..20a52d50 100644 --- a/js/ui.js +++ b/js/ui.js @@ -5125,6 +5125,7 @@ function showCompareSpec() { let loading = document.getElementById('loadingOverlay') loading = loading.cloneNode(true); loading.classList.remove('d-none', 'text-white'); + loading.firstElementChild.textContent = 'Loading audio from Xeno-Canto...' mediaContainer.appendChild(loading); //console.log("id is ", mediaContainer.id) const [specContainer, file] = mediaContainer.getAttribute('name').split('|'); @@ -5167,13 +5168,18 @@ function showCompareSpec() { }), })).initPlugin('spectrogram') + + ws.on('ready', function () { + console.log('ws ready'); + mediaContainer.removeChild(loading); + }) + + ws.load(file) const playButton = document.getElementById('playComparison') // prevent listener accumulation playButton.removeEventListener('click', playComparison) playButton.addEventListener('click', playComparison) - ws.on('load', loading.classList.remove('d-none')) - ws.on('canplay', loading.classList.add('d-none')) } diff --git a/js/worker.js b/js/worker.js index b44e3cf2..43e33164 100644 --- a/js/worker.js +++ b/js/worker.js @@ -592,6 +592,7 @@ const getTotal = async ({species = undefined, offset = undefined, included = [], const useRange = range?.start; let SQL = ` WITH MaxConfidencePerDateTime AS ( SELECT confidence, + speciesID, RANK() OVER (PARTITION BY fileID, dateTime ORDER BY records.confidence DESC) AS rank FROM records JOIN files ON records.fileID = files.id @@ -600,10 +601,6 @@ const getTotal = async ({species = undefined, offset = undefined, included = [], params.push(file) SQL += ' AND files.name = ? ' } - if (species) { - params.push(species); - SQL += ' AND speciesID = (SELECT id from species WHERE cname = ?) '; - }// This will overcount as there may be a valid species ranked above it else if (filtersApplied(included)) SQL += ` AND speciesID IN (${included}) `; if (useRange) SQL += ` AND dateTime BETWEEN ${range.start} AND ${range.end} `; if (STATE.detect.nocmig) SQL += ' AND COALESCE(isDaylight, 0) != 1 '; @@ -620,6 +617,10 @@ const getTotal = async ({species = undefined, offset = undefined, included = [], SQL += ' ) ' SQL += `SELECT COUNT(confidence) AS total FROM MaxConfidencePerDateTime WHERE rank <= ${STATE.topRankin}`; + if (species) { + params.push(species); + SQL += ' AND speciesID = (SELECT id from species WHERE cname = ?) '; + } const {total} = await STATE.db.getAsync(SQL, ...params) return [total, offset, species] } @@ -1060,17 +1061,17 @@ async function setupCtx(audio, rate, destination) { previousFilter ? previousFilter.connect(lowshelfFilter) : offlineSource.connect(lowshelfFilter); previousFilter = lowshelfFilter; } - const from = 2000; - const to = 8000; - const geometricMean = Math.sqrt(from * to); - - const bandpassFilter = offlineCtx.createBiquadFilter(); - bandpassFilter.type = 'bandpass'; - bandpassFilter.frequency.value = geometricMean; - bandpassFilter.Q.value = geometricMean / (to - from); - bandpassFilter.channelCount = 1; - previousFilter ? previousFilter.connect(bandpassFilter) : offlineSource.connect(bandpassFilter); - previousFilter = bandpassFilter; + // const from = 2000; + // const to = 8000; + // const geometricMean = Math.sqrt(from * to); + + // const bandpassFilter = offlineCtx.createBiquadFilter(); + // bandpassFilter.type = 'bandpass'; + // bandpassFilter.frequency.value = geometricMean; + // bandpassFilter.Q.value = geometricMean / (to - from); + // bandpassFilter.channelCount = 1; + // previousFilter ? previousFilter.connect(bandpassFilter) : offlineSource.connect(bandpassFilter); + // previousFilter = bandpassFilter; } } if (STATE.audio.gain){ From 84877a5df956f9f156cdfa4a71b31083726a1efd Mon Sep 17 00:00:00 2001 From: Mattk70 Date: Fri, 22 Mar 2024 15:36:47 +0000 Subject: [PATCH 7/8] Preserve highlighting on table-hover in summary --- css/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/css/style.css b/css/style.css index 0b612060..5364f7fa 100644 --- a/css/style.css +++ b/css/style.css @@ -215,6 +215,10 @@ tbody tr:not(.table-active):hover { z-index: 2; } +/* Stop table-hover overriding the text colour */ +.table-hover tbody tr.text-warning:hover td { + color: #ffae42; /* Change to your desired text color */ +} #resultTableBody { scroll-margin-top: 120px; From 0d271ddc18c6730f4427c3b44e0cda01d7aa0f27 Mon Sep 17 00:00:00 2001 From: Mattk70 Date: Fri, 22 Mar 2024 15:39:27 +0000 Subject: [PATCH 8/8] removed commented out style --- css/style.css | 6 ------ 1 file changed, 6 deletions(-) diff --git a/css/style.css b/css/style.css index 5364f7fa..1c297404 100644 --- a/css/style.css +++ b/css/style.css @@ -200,12 +200,6 @@ ul { color: whitesmoke; /* White color on hover */ } -/* -tbody tr:not(.table-active):hover { - background-color: whitesmoke; -} -*/ - #results>thead, #results>th, #resultSummary>thead,
      ${iconizeScore(item.max)} - ${item.cname}
      ${item.sname}
      + ${item.cname}
      ${item.sname}
      ${item.count} ${item.calls}
      sort Timesort Positionsort Species
      sort Timesort Positionsort Species Calls Label Notes