diff --git a/Help/notification.mp3 b/Help/notification.mp3 new file mode 100644 index 00000000..dd033349 Binary files /dev/null and b/Help/notification.mp3 differ diff --git a/build/icon.icns b/build/icon.icns new file mode 100644 index 00000000..c18a4265 Binary files /dev/null and b/build/icon.icns differ diff --git a/index.html b/index.html index eb613c64..4e281bbd 100644 --- a/index.html +++ b/index.html @@ -294,10 +294,10 @@
Saved Records
? -
+
Threshold: - + @@ -417,6 +417,16 @@
Saved Records
+
+
+
+ + Enable analysis completion notifications + ? +
+
+
@@ -542,7 +552,7 @@
Saved Records
- + @@ -982,7 +992,7 @@
-
@@ -1219,7 +1229,7 @@
-
+
+ @@ -1336,7 +1349,7 @@ } - + \ No newline at end of file diff --git a/js/state.js b/js/state.js index c3f12962..988c257d 100644 --- a/js/state.js +++ b/js/state.js @@ -13,7 +13,7 @@ export class State { this.filteredOffset = {}, // Current species start number for filtered results this.selection = false, this.blocked = [], - this.audio = { gain: 0, format: 'mp3', bitrate: 128, padding: false, fade: false, downmix: false, quality: 5 }, + this.audio = { gain: 0, format: 'mp3', bitrate: 128, padding: false, fade: false, downmix: false, quality: 5, notification: true }, this.filters = { active: false, highPassFrequency: 0, lowShelfFrequency: 0, lowShelfAttenuation: 0, SNR: 0 }, this.detect = { nocmig: false, contextAware: false, confidence: 450 }, this.chart = { range: { start: undefined, end: undefined }, species: undefined }, diff --git a/js/ui.js b/js/ui.js index 1f6996e0..212a1e6d 100644 --- a/js/ui.js +++ b/js/ui.js @@ -112,6 +112,7 @@ const DOM = { audioPadding: document.getElementById('padding'), audioQuality: document.getElementById('quality'), audioQualityContainer: document.getElementById('quality-container'), + audioNotification: document.getElementById('audio-notification'), batchSizeSlider: document.getElementById('batch-size'), batchSizeValue: document.getElementById('batch-size-value'), colourmap: document.getElementById('colourmap'), @@ -889,7 +890,7 @@ function postAnalyseMessage(args) { function fetchLocationAddress(lat, lon) { if (isNaN(lat) || isNaN(lon || lat === '' || lon === '')){ - generateToast({domID:'toastContainer', message:'Both lat and lon values need to be numbers between 180 and -180'}) + generateToast({ message:'Both lat and lon values need to be numbers between 180 and -180'}) return false } return new Promise((resolve, reject) => { @@ -1393,7 +1394,7 @@ window.onload = async () => { tensorflow: { threads: DIAGNOSTICS['Cores'], batchSize: 32 }, webgpu: { threads: 2, batchSize: 32 }, webgl: { threads: 2, batchSize: 32 }, - audio: { gain: 0, format: 'mp3', bitrate: 192, quality: 5, downmix: false, padding: false, fade: false }, + audio: { gain: 0, format: 'mp3', bitrate: 192, quality: 5, downmix: false, padding: false, fade: false, notification: true }, limit: 500, track: true, debug: false @@ -1443,7 +1444,8 @@ window.onload = async () => { // Show Locale document.getElementById('locale').value = config[config.model].locale; - + // remember audio notification setting + DOM.audioNotification.checked = config.audio.notification; config.list === 'location' ? speciesThresholdEl.classList.remove('d-none') : speciesThresholdEl.classList.add('d-none'); @@ -1605,38 +1607,20 @@ const setUpWorkerMessaging = () => { } case "generate-alert": { if (args.updateFilenamePanel) { - renderFilenamePanel(); - window.electron.unsavedRecords(false); - document.getElementById("unsaved-icon").classList.add("d-none"); - } - if (args.file) { - generateToast({domID:'toastContainer', message: args.message}); - } else { - if (args.filter) { - worker.postMessage({ - action: "filter", - species: isSpeciesViewFiltered(true), - active: args.active, - updateSummary: true - }); - resetResults({ - clearSummary: true, - clearPagination: true, - clearResults: true - }); - } else { - generateToast({domID:'toastContainer', message: args.message}); - } + renderFilenamePanel(); + window.electron.unsavedRecords(false); + document.getElementById("unsaved-icon").classList.add("d-none"); } + generateToast({ message: args.message}); break; - } - case "results-complete": {onResultsComplete(args); - hideLoadingSpinner(); - break; - } - case "labels": { - LABELS = args.labels; - break } + } + case "results-complete": {onResultsComplete(args); + hideLoadingSpinner(); + break; + } + case "labels": { + LABELS = args.labels; + break } case "location-list": {LOCATIONS = args.locations; locationID = args.currentLocation; break; @@ -1665,39 +1649,39 @@ const setUpWorkerMessaging = () => { } case "seen-species-list": {generateBirdList("seenSpecies", args.list); break; + } + case "valid-species-list": {populateSpeciesModal(args.included, args.excluded); + break; + } + case "show-spinner": {showLoadingSpinner(500); + break; + } + // case "spawning": {displayWarmUpMessage(); + // break; + // } + case "total-records": {updatePagination(args.total, args.offset); + break; + } + case "unsaved-records": {window.electron.unsavedRecords(true); + document.getElementById("unsaved-icon").classList.remove("d-none"); + break; + } + case "update-audio-duration": {DIAGNOSTICS["Audio Duration"] ??= 0; + DIAGNOSTICS["Audio Duration"] += args.value; + break; + } + case "update-summary": {updateSummary(args); + break; + } + case "worker-loaded-audio": { + onWorkerLoadedAudio(args); + break; + } + default: {generateToast({ message:`Unrecognised message from worker:${args.event}`}); + } } - case "valid-species-list": {populateSpeciesModal(args.included, args.excluded); - break; - } - case "show-spinner": {showLoadingSpinner(500); - break; - } - // case "spawning": {displayWarmUpMessage(); - // break; - // } - case "total-records": {updatePagination(args.total, args.offset); - break; - } - case "unsaved-records": {window.electron.unsavedRecords(true); - document.getElementById("unsaved-icon").classList.remove("d-none"); - break; - } - case "update-audio-duration": {DIAGNOSTICS["Audio Duration"] ??= 0; - DIAGNOSTICS["Audio Duration"] += args.value; - break; - } - case "update-summary": {updateSummary(args); - break; - } - case "worker-loaded-audio": { - onWorkerLoadedAudio(args); - break; - } - default: {generateToast({domID:'toastContainer', message:`Unrecognised message from worker:${args.event}`}); -} -} -}) -}) + }) + }) } function generateBirdList(store, rows) { @@ -2399,7 +2383,7 @@ function onChartData(args) { threads: config[config.backend].threads, list: config.list }); - generateToast({domID:'toastContainer', message:'Operation cancelled'}); + generateToast({ message:'Operation cancelled'}); DOM.progressDiv.classList.add('d-none'); } }, @@ -2614,7 +2598,7 @@ function onChartData(args) { seconds = Math.min(parseFloat(timeArray[2]), 59.999); } else { // Invalid input - generateToast({domID:'toastContainer', message:'Invalid time format. Please enter time in one of the following formats: \n1. Float (for seconds) \n2. Two numbers separated by a colon (for minutes and seconds) \n3. Three numbers separated by colons (for hours, minutes, and seconds)'}); + generateToast({ message:'Invalid time format. Please enter time in one of the following formats: \n1. Float (for seconds) \n2. Two numbers separated by a colon (for minutes and seconds) \n3. Three numbers separated by colons (for hours, minutes, and seconds)'}); return; } let start = hours * 3600 + minutes * 60 + seconds; @@ -2863,6 +2847,7 @@ function onChartData(args) { track(`${config.model}-${config.backend}`, 'Audio Duration', config.backend, Math.round(DIAGNOSTICS['Audio Duration'])); track(`${config.model}-${config.backend}`, 'Analysis Duration', config.backend, parseInt(analysisTime)); track(`${config.model}-${config.backend}`, 'Analysis Rate', config.backend, parseInt(rate)); + generateToast({ message:'Analysis complete.'}) } /* @@ -2986,7 +2971,7 @@ function onChartData(args) { let tr = ''; if (typeof (result) === 'string') { // const nocturnal = config.detect.nocmig ? 'during the night' : ''; - generateToast({domID:'toastContainer', message: result}); + generateToast({ message: result}); return } if (index <= 1) { @@ -3240,7 +3225,7 @@ function onChartData(args) { }) } else { if (!config.seenThanks) { - generateToast({domID:'toastContainer', message:'Thank you, your feedback helps improve Chirpity predictions'}); + generateToast({ message:'Thank you, your feedback helps improve Chirpity predictions'}); config.seenThanks = true; updatePrefs() } @@ -4062,7 +4047,7 @@ DOM.gain.addEventListener('input', () => { case 'species-frequency-threshold' : { if (isNaN(element.value) || element.value === '') { - generateToast({domID:'toastContainer', message:'The threshold must be a number between 0.001 and 1'}); + generateToast({ message:'The threshold must be a number between 0.001 and 1'}); return false } config.speciesThreshold = element.value; @@ -4107,6 +4092,10 @@ DOM.gain.addEventListener('input', () => { handleSNRchange(e); break; } + case 'audio-notification': { + config.audio.notification = element.checked; + break; + } case 'species-week': { config.useWeek = element.checked; @@ -4452,6 +4441,7 @@ function track(event, action, name, value){ const insertManualRecord = (cname, start, end, comment, count, label, action, batch, originalCname, confidence) => { + resetResults({clearPagination: false}) const files = batch ? fileList : currentFile; worker.postMessage({ action: 'insert-manual-record', @@ -4639,8 +4629,8 @@ function track(event, action, name, value){ } } - function generateToast({domID, message}) { - const domEl = document.getElementById(domID); + function generateToast({message}) { + const domEl = document.getElementById('toastContainer'); const wrapper = document.createElement('div'); // Add toast attributes @@ -4663,6 +4653,29 @@ function track(event, action, name, value){ domEl.appendChild(wrapper) const toast = new bootstrap.Toast(wrapper) toast.show() + if (message === 'Analysis complete.'){ + const duration = parseFloat(DIAGNOSTICS['Analysis Duration'].replace(' seconds', '')); + if (config.audio.notification && duration > 30){ + if (Notification.permission === "granted") { + // Check whether notification permissions have already been granted; + // if so, create a notification + const notification = new Notification(`Analysis completed in ${duration.toFixed(0)} seconds`, {requireInteraction: true}); + // … + } else if (Notification.permission !== "denied") { + // We need to ask the user for permission + Notification.requestPermission().then((permission) => { + // If the user accepts, let's create a notification + if (permission === "granted") { + const notification = new Notification(`Analysis completed in ${duration.toFixed(0)} seconds`, {requireInteraction: true}); + // … + } + }); + } else { + notificationSound = document.getElementById('notification'); + notificationSound.play() + } + } + } } function parseSemVer(versionString) { diff --git a/js/worker.js b/js/worker.js index ab86c8f4..3cfa507f 100644 --- a/js/worker.js +++ b/js/worker.js @@ -1862,7 +1862,8 @@ const prepSummaryStatement = (included) => { const updatedID = db.getAsync('SELECT id FROM species WHERE cname = ?', cname); let count = 0; await db.runAsync('BEGIN'); - for (const item of records) { + 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 @@ -1876,18 +1877,19 @@ const prepSummaryStatement = (included) => { file: name, label: label, batch: false, - originalCname: undefined + 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 }) => { + 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, fileID, fileStart; + 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); @@ -1919,15 +1921,12 @@ const prepSummaryStatement = (included) => { if (response.changes){ STATE.db === diskDB ? UI.postMessage({ event: 'diskDB-has-records' }) : UI.postMessage({event: 'unsaved-records'}); } - // WHY NOT USE FILTER DIRECTLY? It's to get species and offset - // UI.postMessage({ - // event: 'generate-alert', - // // message: `${count} ${args.cname} record has been saved to the archive.`, - // filter: true, - // active: active - // }) - await getResults({species:speciesFiltered, select: start}); - await getSummary({species: speciesFiltered}); + 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) => { @@ -2282,6 +2281,58 @@ const prepSummaryStatement = (included) => { }) }; + + const getPosition = async ({species = undefined, dateTime = undefined, included = []} = {}) => { + const params = [STATE.detect.confidence]; + let positionStmt = ` + WITH ranked_records AS ( + SELECT + dateTime, + RANK() OVER (PARTITION BY records.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 >= ? + `; + // might have two locations with same dates - so need to add files + if (['analyse', 'archive'].includes(STATE.mode) && !STATE.selection) { + 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 ${range.start} AND ${range.end} `; + params.push(range.start,range.end) + } + if (filtersApplied(included)){ + const included = await getIncludedIDs(); + positionStmt += ` AND speciesID IN (${prepParams(included)}) `; + params.push(...STATE.included) + } + if (STATE.locationID) { + positionStmt += ` AND locationID = ${STATE.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 + } /** * @@ -2306,21 +2357,18 @@ const prepSummaryStatement = (included) => { select = undefined } = {}) => { let confidence = STATE.detect.confidence; - if (offset === undefined) { // Get offset state - if (species) { - if (!STATE.filteredOffset[species]) STATE.filteredOffset[species] = 0; - offset = STATE.filteredOffset[species]; - } else { - offset = STATE.globalOffset; - } - } else { // Set offset state - if (species) STATE.filteredOffset[species] = offset; - else STATE.update({ globalOffset: offset }); + 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; } + 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 included = STATE.selection ? [] : await getIncludedIDs(); const params = getResultsParams(species, confidence, offset, limit, topRankin, included); prepResultsStatement(species, limit === Infinity, included); @@ -2372,7 +2420,7 @@ const prepSummaryStatement = (included) => { } } } - STATE.selection || UI.postMessage({event: 'results-complete', active: active, select: select}); + STATE.selection || UI.postMessage({event: 'results-complete', active: active, select: select?.start}); }; // Function to format the CSV export diff --git a/main.js b/main.js index f4591846..7fd8fbe2 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, dialog, ipcMain, MessageChannelMain, BrowserWindow, globalShortcut } = require('electron'); +const { app, Menu, dialog, ipcMain, MessageChannelMain, BrowserWindow, globalShortcut } = require('electron'); const { autoUpdater } = require("electron-updater") const log = require('electron-log'); @@ -24,6 +24,22 @@ console.error = log.error; autoUpdater.logger = log; autoUpdater.logger.transports.file.level = 'info'; +const menu = Menu.buildFromTemplate([{ + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }]); + +Menu.setApplicationMenu(menu); // Updates // Function to fetch release notes from GitHub API async function fetchReleaseNotes(version) { @@ -323,6 +339,7 @@ async function createWorker() { // This method will be called when Electron has finished loading app.whenReady().then(async () => { + ipcMain.handle('getPath', () => app.getPath('userData')); ipcMain.handle('getTemp', () => app.getPath('temp')); ipcMain.handle('getVersion', () => app.getVersion()); @@ -357,6 +374,7 @@ app.whenReady().then(async () => { }) } else { // Quit when all windows are closed. + app.setAppUserModelId('chirpity') app.on('window-all-closed', () => { app.quit() }) diff --git a/package.json b/package.json index 8686e79a..32de4648 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chirpity", - "version": "1.1.0", + "version": "1.2.0", "description": "Chirpity Nocmig", "main": "main.js", "scripts": { @@ -14,6 +14,7 @@ "url": "git+https://github.com/mattk70/Chirpity-Electron.git" }, "build": { + "appId": "com.electron.chirpity", "publish": [ { "provider": "github", @@ -108,7 +109,7 @@ "!node_modules/ffprobe-static-electron/bin/win${/*}", "!node_modules/ffprobe-static-electron/bin/linux${/*}" ], - "icon": "./img/icon/icon.icns", + "icon": "img/icon/icon.icns", "category": "public.app-category.utilities", "fileAssociations": [ {