diff --git a/css/style.css b/css/style.css
index a19379fe..79c99e67 100644
--- a/css/style.css
+++ b/css/style.css
@@ -572,7 +572,7 @@ input[type="range"].vertical {
}
}
-#loadingOverlay {
+#loading {
position: absolute;
top: 20vh;
}
@@ -768,7 +768,7 @@ input[type="range"].vertical {
z-index: 1;
}
- .guano-popover {
+ .metadata-popover {
--bs-popover-max-width: 500px;
max-height: 500px;
--bs-popover-border-color: var(--bs-primary);
@@ -779,12 +779,12 @@ input[type="range"].vertical {
overflow: hidden;
}
- .guano {
+ .metadata {
max-height: 450px;
overflow-y: scroll;
}
- .guano table {
+ .metadata table {
width: 100%; /* Ensure the table takes full width */
table-layout: auto;
}
\ No newline at end of file
diff --git a/index.html b/index.html
index fc64fddd..e755b2bb 100644
--- a/index.html
+++ b/index.html
@@ -1122,8 +1122,8 @@
-
- other_admission
+
+ other_admission
warning
diff --git a/js/guano.js b/js/guano.js
deleted file mode 100644
index e5f142b9..00000000
--- a/js/guano.js
+++ /dev/null
@@ -1,117 +0,0 @@
-////////// GUANO Support /////////////
-
-const fs = require('node:fs');
-
-/**
- * Extract GUANO metadata from a WAV file, without reading the entire file into memory.
- * @param {string} filePath - Path to the WAV file.
- * @returns {Promise
';
+ return tableHtml
}
-function showGUANO(){
- const icon = document.getElementById('guano');
- if (STATE.guano[STATE.currentFile]){
+function showMetadata(){
+ const icon = document.getElementById('metadata');
+ if (STATE.metadata[STATE.currentFile]){
icon.classList.remove('d-none');
icon.setAttribute('data-bs-content', 'New content for the popover');
// Reinitialize the popover to reflect the updated content
const popover = bootstrap.Popover.getInstance(icon);
popover.setContent({
- '.popover-header': 'GUANO Metadata',
- '.popover-body': formatAsBootstrapTable(STATE.guano[STATE.currentFile])
+ '.popover-header': 'Metadata',
+ '.popover-body': formatAsBootstrapTable(STATE.metadata[STATE.currentFile])
});
} else {
icon.classList.add('d-none');
@@ -734,7 +743,7 @@ function renderFilenamePanel() {
if (!STATE.currentFile) return;
const openfile = STATE.currentFile;
const files = STATE.openFiles;
- showGUANO();
+ showMetadata();
let filenameElement = DOM.filename;
filenameElement.innerHTML = '';
//let label = openfile.replace(/^.*[\\\/]/, "");
@@ -1265,7 +1274,7 @@ async function exportData(format, species, limit, duration){
const handleLocationFilterChange = (e) => {
- const location = e.target.value || undefined;
+ const location = parseInt(e.target.value) || undefined;
worker.postMessage({ action: 'update-state', locationID: location });
// Update the seen species list
worker.postMessage({ action: 'get-detected-species-list' })
@@ -1275,10 +1284,10 @@ const handleLocationFilterChange = (e) => {
function saveAnalyseState() {
if (['analyse', 'archive'].includes(STATE.mode)){
- const active = activeRow?.rowIndex -1
+ const active = activeRow?.rowIndex -1 || null
// Store a reference to the current file
STATE.currentAnalysis = {
- file: STATE.currentFile,
+ currentFile: STATE.currentFile,
openFiles: STATE.openFiles,
mode: STATE.mode,
species: isSpeciesViewFiltered(true),
@@ -1334,11 +1343,16 @@ async function showAnalyse() {
if (STATE.currentFile) {
showElement(['spectrogramWrapper'], false);
worker.postMessage({ action: 'update-state', filesToAnalyse: STATE.openFiles, sortOrder: STATE.sortOrder});
- STATE.analysisDone && worker.postMessage({ action: 'filter',
- species: STATE.species,
- offset: STATE.offset,
- active: STATE.active,
- updateSummary: true });
+ if (STATE.analysisDone) {
+ worker.postMessage({ action: 'filter',
+ species: STATE.species,
+ offset: STATE.offset,
+ active: STATE.active,
+ updateSummary: true });
+ } else {
+ clearActive();
+ loadAudioFile({filePath: STATE.currentFile});
+ }
}
resetResults();
};
@@ -1764,19 +1778,6 @@ window.onload = async () => {
};
//fill in defaults - after updates add new items
syncConfig(config, defaultConfig);
- // Reset defaults for tensorflow batchsize. If removing, update change handler for batch-size
- if (config.tensorflow.batchSizeWasReset !== true && config.tensorflow.batchSize === 32) {
- const RAM = parseInt(DIAGNOSTICS['System Memory'].replace(' GB', ''));
- if (!RAM || RAM < 16){
- config.tensorflow.batchSize = 8;
- generateToast({message: `The new default CPU backend batch size of 8 has been applied.
- This should result in faster prediction due to lower memory requirements.
- Batch size can still be changed in settings`, autohide: false})
- }
- config.tensorflow.batchSizeWasReset = true;
- updatePrefs('config.json', config)
- }
-
// set version
config.VERSION = VERSION;
// switch off debug mode we don't want this to be remembered
@@ -1987,10 +1988,10 @@ const setUpWorkerMessaging = () => {
MISSING_FILE = args.file;
args.message += `
-
@@ -3149,7 +3150,7 @@ function centreSpec(){
play = false,
queued = false,
goToRegion = true,
- guano = undefined
+ metadata = undefined
}) {
fileLoaded = true, locationID = location;
clearTimeout(loadingTimeout)
@@ -3180,7 +3181,7 @@ function centreSpec(){
fileEnd = new Date(fileStart + (currentFileDuration * 1000));
}
- STATE.guano[STATE.currentFile] = guano;
+ STATE.metadata[STATE.currentFile] = metadata;
renderFilenamePanel();
if (config.timeOfDay) {
@@ -3378,6 +3379,7 @@ function formatDuration(seconds){
STATE.mode !== 'explore' && enableMenuItem(['save2db'])
}
if (STATE.currentFile) enableMenuItem(['analyse'])
+
adjustSpecDims(true)
}
@@ -3435,8 +3437,6 @@ function formatDuration(seconds){
})
}
- const summary = document.getElementById('summary');
- // summary.addEventListener('click', speciesFilter);
function speciesFilter(e) {
if (PREDICTING || ['TBODY', 'TH', 'DIV'].includes(e.target.tagName)) return; // on Drag or clicked header
@@ -3447,7 +3447,7 @@ function formatDuration(seconds){
e.target.closest('tr').classList.remove('text-warning');
} else {
//Clear any highlighted rows
- const tableRows = summary.querySelectorAll('tr');
+ const tableRows = DOM.summary.querySelectorAll('tr');
tableRows.forEach(row => row.classList.remove('text-warning'))
// Add a highlight to the current row
e.target.closest('tr').classList.add('text-warning');
@@ -3459,7 +3459,7 @@ function formatDuration(seconds){
const list = document.getElementById('bird-list-seen');
list.value = species || '';
}
- filterResults()
+ filterResults({updateSummary: false})
resetResults({clearSummary: false, clearPagination: false, clearResults: false});
}
@@ -3699,25 +3699,25 @@ function formatDuration(seconds){
const dateString = dateArray.slice(0, 5).join(' ');
filename = dateString + '.' + config.audio.format;
}
-
+
let metadata = {
- lat: config.latitude,
- lon: config.longitude,
+ lat: parseFloat(config.latitude),
+ lon: parseFloat(config.longitude),
Artist: 'Chirpity',
- date: new Date().getFullYear(),
version: VERSION
};
if (result) {
+ const date = new Date(result.timestamp);
metadata = {
...metadata,
UUID: config.UUID,
start: start,
end: end,
- filename: result.filename,
+ //filename: result.file,
cname: result.cname,
sname: result.sname,
- score: result.score,
- date: result.date
+ score: parseInt(result.score),
+ Year: date.getFullYear()
};
}
@@ -4037,7 +4037,7 @@ function formatDuration(seconds){
}
});
// Add pointer icon to species summaries
- const summarySpecies = document.getElementById('summary').querySelectorAll('.cname');
+ const summarySpecies = DOM.summary.querySelectorAll('.cname');
summarySpecies.forEach(row => row.classList.add('pointer'))
// change results header to indicate activation
DOM.resultHeader.classList.remove('text-bg-secondary');
@@ -4808,9 +4808,6 @@ DOM.gain.addEventListener('input', () => {
} else {
DOM.batchSizeValue.textContent = BATCH_SIZE_LIST[DOM.batchSizeSlider.value].toString();
config[config[config.model].backend].batchSize = BATCH_SIZE_LIST[element.value];
- // Need this in case a non-default batchsize was set, and then changed to 32
- if (config[config.model].backend === 'tensorflow') config.tensorflow.batchSizeWasReset = true;
-
worker.postMessage({action: 'change-batch-size', batchSize: BATCH_SIZE_LIST[element.value]})
// Reset region maxLength
initRegion();
diff --git a/js/worker.js b/js/worker.js
index 3b649205..b229d285 100644
--- a/js/worker.js
+++ b/js/worker.js
@@ -14,6 +14,7 @@ const merge = require('lodash.merge');
import { State } from './state.js';
import { sqlite3 } from './database.js';
import {trackEvent} from './tracking.js';
+import {extractWaveMetadata} from './metadata.js';
const DEBUG = true;
@@ -117,7 +118,60 @@ let diskDB, memoryDB;
let t0; // Application profiler
+const setupFfmpegCommand = ({
+ file,
+ start = 0,
+ end = undefined,
+ sampleRate = 24000,
+ channels = 1,
+ format = 'wav',
+ additionalFilters = [],
+ metadata = {},
+ audioCodec = null,
+ audioBitrate = null,
+ outputOptions = []
+}) => {
+ const command = ffmpeg('file:' + file)
+ .seekInput(start)
+ .duration(end - start)
+ .format(format)
+ .audioChannels(channels)
+ .audioFrequency(sampleRate);
+
+ if (metadata.length) command.addOutputOptions(metadata);
+ // Add filters if provided
+ additionalFilters.forEach(filter => {
+ command.audioFilters(filter);
+ });
+ if (format === 'flac') command.audioFormat('s16')
+
+ if (format !== 'flac' && Object.keys(metadata).length > 0) {
+ metadata = Object.entries(metadata).flatMap(([k, v]) => {
+ if (typeof v === 'string') {
+ // Escape special characters, including quotes and apostrophes
+ v=v.replaceAll(' ', '_');
+ };
+ return ['-metadata', `${k}=${v}`]
+ });
+ command.addOutputOptions(metadata)
+ }
+
+ // Set codec if provided
+ if (audioCodec) command.audioCodec(audioCodec);
+
+ // Set bitRate if provided
+ if (audioBitrate) command.audioBitrate(audioBitrate);
+ // Add any additional output options
+ if (outputOptions.length) command.addOutputOptions(...outputOptions);
+
+ if (DEBUG){
+ command.on('start', function (commandLine) {
+ console.log('FFmpeg command: ' + commandLine);
+ })
+ }
+ return command;
+};
const getSelectionRange = (file, start, end) => {
@@ -1078,7 +1132,7 @@ async function locateFile(file) {
}
} catch (error) {
if (error.message.includes('scandir')){
- const match = str.match(/'([^']+)'/);
+ const match = error.message.match(/'([^']+)'/);
UI.postMessage({
event: 'generate-alert', type: 'warning',
message: `Unable to locate folder "${match}". Perhaps the disk was removed?`
@@ -1131,7 +1185,7 @@ async function loadAudioFile({
play: play,
queued: queued,
goToRegion,
- guano: METADATA[file].guano
+ metadata: METADATA[file].metadata
}, [audioArray.buffer]);
})
.catch( (error) => {
@@ -1184,12 +1238,10 @@ const setMetadata = async ({ file, source_file = file }) => {
throw new Error('Unable to determine file duration ', e);
}
if (file.toLowerCase().endsWith('wav')){
- const {extractGuanoMetadata} = await import('./guano.js').catch(error => {
- console.warn('Error loading guano.js', error)}
- );
const t0 = Date.now();
- const guano = await extractGuanoMetadata(file).catch(error => console.warn("Error extracting GUANO", error));
- if (guano){
+ const wavMetadata = await extractWaveMetadata(file).catch(error => console.warn("Error extracting GUANO", error));
+ if (Object.keys(wavMetadata).includes('guano')){
+ const guano = wavMetadata.guano;
const location = guano['Loc Position'];
if (location){
const [lat, lon] = location.split(' ');
@@ -1198,7 +1250,9 @@ const setMetadata = async ({ file, source_file = file }) => {
}
guanoTimestamp = Date.parse(guano.Timestamp);
METADATA[file].fileStart = guanoTimestamp;
- METADATA[file].guano = JSON.stringify(guano);
+ }
+ if (Object.keys(wavMetadata).length > 0){
+ METADATA[file].metadata = JSON.stringify(wavMetadata);
}
DEBUG && console.log(`GUANO search took: ${(Date.now() - t0)/1000} seconds`);
}
@@ -1491,18 +1545,11 @@ const getPredictBuffers = async ({
predictionsReceived[file] = 0;
predictionsRequested[file] = 0;
let highWaterMark = 2 * sampleRate * BATCH_SIZE * WINDOW_SIZE;
-
let chunkStart = start * sampleRate;
return new Promise((resolve, reject) => {
let concatenatedBuffer = Buffer.alloc(0);
- const command = ffmpeg('file:' + file)
- .seekInput(start)
- .duration(end - start)
- .format('wav')
- .audioChannels(1) // Set to mono
- .audioFrequency(sampleRate) // Set sample rate
-
+ const command = setupFfmpegCommand({file, start, end, sampleRate})
command.on('error', (error) => {
updateFilesBeingProcessed(file)
if (error.message.includes('SIGKILL')) console.log('FFMPEG process shut down at user request')
@@ -1512,9 +1559,6 @@ const getPredictBuffers = async ({
console.log('Ffmpeg error in file:\n', file, 'stderr:\n', error)
reject(console.warn('getPredictBuffers: Error in ffmpeg extracting audio segment:', error));
});
- command.on('start', function (commandLine) {
- DEBUG && console.log('FFmpeg command: ' + commandLine);
- })
const STREAM = command.pipe();
STREAM.on('readable', () => {
@@ -1548,25 +1592,30 @@ const getPredictBuffers = async ({
// Initally, the highwatermark needs to add the header length to get the correct length of audio
if (header) highWaterMark += header.length;
}
-
// if we have a full buffer
if (concatenatedBuffer.length > highWaterMark) {
- const audio_chunk = Buffer.allocUnsafe(highWaterMark);
- concatenatedBuffer.copy(audio_chunk, 0, 0, highWaterMark);
- const remainder = Buffer.allocUnsafe(concatenatedBuffer.length - highWaterMark);
- concatenatedBuffer.copy(remainder, 0, highWaterMark);
- const noHeader = audio_chunk.compare(header, 0, header.length, 0, header.length)
- const audio = noHeader ? joinBuffers(header, audio_chunk) : audio_chunk;
- // If we *do* have a header, we need to reset highwatermark because subsequent chunks *won't* have it
- if (! noHeader) {
- highWaterMark -= header.length;
- shortFile = false;
- }
+ // const audio_chunk = Buffer.allocUnsafe(highWaterMark);
+ // concatenatedBuffer.copy(audio_chunk, 0, 0, highWaterMark);
+ // const remainder = Buffer.allocUnsafe(concatenatedBuffer.length - highWaterMark);
+
+ // concatenatedBuffer.copy(remainder, 0, highWaterMark);
+ // const noHeader = audio_chunk.compare(header, 0, header.length, 0, header.length)
+ // const audio = noHeader ? joinBuffers(header, audio_chunk) : audio_chunk;
+ // // If we *do* have a header, we need to reset highwatermark because subsequent chunks *won't* have it
+ // if (! noHeader) {
+ // highWaterMark -= header.length;
+ // shortFile = false;
+ // }
+ // processPredictQueue(audio, file, end, chunkStart);
+ // chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate
+ // concatenatedBuffer = remainder;
+ const audio_chunk = concatenatedBuffer.subarray(0, highWaterMark);
+ const remainder = concatenatedBuffer.subarray(highWaterMark);
+ const audio = header ? Buffer.concat([header, audio_chunk]) : audio_chunk;
processPredictQueue(audio, file, end, chunkStart);
- chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate
+ chunkStart += WINDOW_SIZE * BATCH_SIZE * sampleRate;
concatenatedBuffer = remainder;
-
}
}
});
@@ -1619,34 +1668,28 @@ const fetchAudioBuffer = async ({
// Use ffmpeg to extract the specified audio segment
if (isNaN(start)) throw(new Error('fetchAudioBuffer: start is NaN'));
return new Promise((resolve, reject) => {
- let command = ffmpeg('file:' + file)
- .seekInput(start)
- .duration(end - start)
- .format('wav')
- .audioChannels(1) // Set to mono
- .audioFrequency(24_000) // Set sample rate to 24000 Hz (always - this is for wavesurfer)
- if (STATE.filters.active) {
- if (STATE.filters.lowShelfAttenuation && STATE.filters.lowShelfFrequency){
- command.audioFilters({
- filter: 'lowshelf',
- options: `gain=${STATE.filters.lowShelfAttenuation}:f=${STATE.filters.lowShelfFrequency}`
- })
+ const command = setupFfmpegCommand({
+ file,
+ start,
+ end,
+ sampleRate: 24000,
+ format: 'wav',
+ channels: 1,
+ additionalFilters: [
+ STATE.filters.lowShelfAttenuation && {
+ filter: 'lowshelf',
+ options: `gain=${STATE.filters.lowShelfAttenuation}:f=${STATE.filters.lowShelfFrequency}`
+ },
+ STATE.filters.highPassFrequency && {
+ filter: 'highpass',
+ options: `f=${STATE.filters.highPassFrequency}:poles=1`
+ },
+ STATE.audio.normalise && {
+ filter: 'loudnorm',
+ options: "I=-16:LRA=11:TP=-1.5"
}
- if (STATE.filters.highPassFrequency){
- command.audioFilters({
- filter: 'highpass',
- options: `f=${STATE.filters.highPassFrequency}:poles=1`
- })
- }
- }
- if (STATE.audio.normalise){
- command.audioFilters(
- {
- filter: 'loudnorm',
- options: "I=-16:LRA=11:TP=-1.5"
- }
- )
- }
+ ].filter(Boolean),
+ });
const stream = command.pipe();
command.on('error', error => {
@@ -2181,7 +2224,7 @@ const onInsertManualRecord = async ({ cname, start, end, comment, count, file, l
// Manual records can be added off the bat, so there may be no record of the file in either db
fileStart = METADATA[file].fileStart;
res = await db.runAsync('INSERT OR IGNORE INTO files VALUES ( ?,?,?,?,?,? )',
- fileID, file, METADATA[file].duration, fileStart, undefined, undefined, METADATA[file].guano);
+ fileID, file, METADATA[file].duration, fileStart, undefined, undefined, METADATA[file].metadata);
fileID = res.lastID;
changes = 1;
let durationSQL = Object.entries(METADATA[file].dateDuration)
@@ -2227,8 +2270,9 @@ const generateInsertQuery = async (latestResult, file) => {
let res = await db.getAsync('SELECT id FROM files WHERE name = ?', file);
if (!res) {
let id = null;
- if (METADATA[file].guano){
- const guano = JSON.parse(METADATA[file].guano);
+ if (METADATA[file].metadata){
+ const metadata = JSON.parse(METADATA[file].metadata);
+ const guano = metadata.guano;
if (guano['Loc Position']){
const [lat, lon] = guano['Loc Position'].split(' ');
const place = guano['Site Name'] || guano['Loc Position'];
@@ -2241,7 +2285,7 @@ const generateInsertQuery = async (latestResult, file) => {
}
}
res = await db.runAsync('INSERT OR IGNORE INTO files VALUES ( ?,?,?,?,?,?,? )',
- undefined, file, METADATA[file].duration, METADATA[file].fileStart, id, null, METADATA[file].guano);
+ undefined, file, METADATA[file].duration, METADATA[file].fileStart, id, null, METADATA[file].metadata);
fileID = res.lastID;
await insertDurations(file, fileID);
} else {
diff --git a/main.js b/main.js
index 282c026b..024e7cd4 100644
--- a/main.js
+++ b/main.js
@@ -487,6 +487,7 @@ app.whenReady().then(async () => {
autoUpdater.checkForUpdatesAndNotify()
// Allow multiple instances of Chirpity - experimental! This alone doesn't work:
//app.releaseSingleInstanceLock()
+
});
diff --git a/preload.js b/preload.js
index 529bd111..92c0e3b8 100644
--- a/preload.js
+++ b/preload.js
@@ -39,7 +39,10 @@ ipcRenderer.once('provide-worker-channel', async (event) => {
window.postMessage('provide-worker-channel', '/', event.ports)
})
-
+ipcRenderer.on('error', (event, errorMessage) => {
+ console.error('Uncaught Exception from main process:', errorMessage);
+ alert('Uncaught Exception from main process:', errorMessage)
+ });
contextBridge.exposeInMainWorld('electron', {
requestWorkerChannel: () => ipcRenderer.invoke('request-worker-channel'),