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} - The extracted GUANO metadata or null if not found. - */ -function extractGuanoMetadata(filePath) { - return new Promise((resolve, reject) => { - // Open the file - fs.open(filePath, 'r', (err, fd) => { - if (err) return reject(err); - - const buffer = Buffer.alloc(12); // Initial buffer for RIFF header and first chunk header - - // Read the RIFF header (12 bytes) - fs.read(fd, buffer, 0, 12, 0, (err) => { - if (err) return reject(err); - - const chunkId = buffer.toString('utf-8', 0, 4); // Should be "RIFF" - const format = buffer.toString('utf-8', 8, 12); // Should be "WAVE" - - if (chunkId !== 'RIFF' || format !== 'WAVE') { - return reject(new Error('Invalid WAV file')); - } - - let currentOffset = 12; // Start after the RIFF header - - // Function to read the next chunk header - function readNextChunk() { - const chunkHeaderBuffer = Buffer.alloc(8); // 8 bytes for chunk ID and size - fs.read(fd, chunkHeaderBuffer, 0, 8, currentOffset, (err) => { - if (err) return reject(err); - - const chunkId = chunkHeaderBuffer.toString('utf-8', 0, 4); // Chunk ID - const chunkSize = chunkHeaderBuffer.readUInt32LE(4); // Chunk size - if (chunkSize === 0) return resolve(null) // No GUANO found - - currentOffset += 8; // Move past the chunk header - - if (chunkId === 'guan') { - // GUANO chunk found, read its content - const guanoBuffer = Buffer.alloc(chunkSize); - fs.read(fd, guanoBuffer, 0, chunkSize, currentOffset, (err) => { - if (err) return reject(err); - - // GUANO data is UTF-8 encoded - const guanoText = guanoBuffer.toString('utf-8'); - const guanoMetadata = _parseGuanoText(guanoText); - resolve(guanoMetadata); - - fs.close(fd, () => {}); // Close the file descriptor - }); - } else if (chunkId === 'data') { - // Skip over the data chunk (just move the offset) - currentOffset += chunkSize; - // Handle padding if chunkSize is odd - if (chunkSize % 2 !== 0) currentOffset += 1; - readNextChunk(); // Continue reading after skipping the data chunk - } else { - // Skip over any other chunk - currentOffset += chunkSize; - // Handle padding if chunkSize is odd - if (chunkSize % 2 !== 0) currentOffset += 1; - readNextChunk(); // Continue reading - } - }); - } - - // Start reading chunks after the RIFF header - readNextChunk(); - }); - }); - }); -} - - -/** - * Helper function to parse GUANO text into key-value pairs - * @param {string} guanoText - GUANO text data - * @returns {object} Parsed GUANO metadata - */ -function _parseGuanoText(guanoText) { - const guanoMetadata = {}; - // According to the GUANO Spec, the note field can contain escaped newline characters '\\n' - // So, we'll substitute a placeholder to avoid conflicts - const _tempGuano = guanoText.replaceAll('\\n', '\uFFFF'); - const lines = _tempGuano.split('\n'); - - lines.forEach(line => { - const colonIndex = line.indexOf(':'); - if (colonIndex !== -1) { - const key = line.slice(0, colonIndex).trim(); - // Replace the placeholder with '\n' - const value = line.slice(colonIndex + 1).trim().replaceAll('\uFFFF', '\n'); - - try { - // Attempt to parse JSON-like values - if ((value.startsWith('[') && value.endsWith(']')) || - (value.startsWith('{') && value.endsWith('}'))) { - guanoMetadata[key] = JSON.parse(value); - } else { - guanoMetadata[key] = value; - } - } catch { - guanoMetadata[key] = value; - } - } - }); - - return guanoMetadata; -} - - -export {extractGuanoMetadata} \ No newline at end of file diff --git a/js/metadata.js b/js/metadata.js new file mode 100644 index 00000000..afa8fcc5 --- /dev/null +++ b/js/metadata.js @@ -0,0 +1,143 @@ +////////// GUANO Support ///////////// + +const fs = require('node:fs'); + +/** + * Extract metadata from a WAV file, without reading the entire file into memory. + * @param {string} filePath - Path to the WAV file. + * @returns {Promise} - The extracted metadata or null if not found. + */ +function extractWaveMetadata(filePath) { + let metadata = {} + return new Promise((resolve, reject) => { + // Open the file + fs.open(filePath, 'r', (err, fd) => { + if (err) return reject(err); + + const buffer = Buffer.alloc(12); // Initial buffer for RIFF header and first chunk header + + // Read the RIFF header (12 bytes) + fs.read(fd, buffer, 0, 12, 0, (err) => { + if (err) return reject(err); + + const chunkId = buffer.toString('utf-8', 0, 4); // Should be "RIFF" + const format = buffer.toString('utf-8', 8, 12); // Should be "WAVE" + + if (chunkId !== 'RIFF' || format !== 'WAVE') { + return reject(new Error('Invalid WAV file')); + } + + let currentOffset = 12; // Start after the RIFF header + + // Function to read the next chunk header + function readNextChunk() { + const chunkHeaderBuffer = Buffer.alloc(8); // 8 bytes for chunk ID and size + fs.read(fd, chunkHeaderBuffer, 0, 8, currentOffset, (err) => { + if (err) return reject(err); + + const chunkId = chunkHeaderBuffer.toString('utf-8', 0, 4); // Chunk ID + const chunkSize = chunkHeaderBuffer.readUInt32LE(4); // Chunk size + if (chunkSize === 0) { + fs.close(fd, () => {}); // Close the file descriptor + resolve(metadata) // No GUANO found + } + + currentOffset += 8; // Move past the chunk header + + if (chunkId === 'guan') { + // GUANO chunk found, read its content + const guanoBuffer = Buffer.alloc(chunkSize); + fs.read(fd, guanoBuffer, 0, chunkSize, currentOffset, (err) => { + if (err) return reject(err); + + // GUANO data is UTF-8 encoded + const guanoText = guanoBuffer.toString('utf-8'); + const guano = _parseMetadataText(guanoText); + metadata['guano'] = guano; + + }); + } else if (chunkId === 'bext') { + // GUANO chunk found, read its content + const bextBuffer = Buffer.alloc(chunkSize); + fs.read(fd, bextBuffer, 0, chunkSize, currentOffset, (err) => { + if (err) return reject(err); + const bext = { + Description: bextBuffer.toString('ascii', 0, 256).replaceAll('\\u000', ''), + Originator: bextBuffer.toString('ascii', 256, 288).replaceAll('\\u000', ''), + OriginatorReference: bextBuffer.toString('ascii', 288, 320).replaceAll('\\u000', ''), + OriginationDate: bextBuffer.toString('ascii', 320, 330).replaceAll('\\u000', ''), + OriginationTime: bextBuffer.toString('ascii', 330, 338).trim(), + TimeReferenceLow: bextBuffer.readUInt32LE(338), + TimeReferenceHigh: bextBuffer.readUInt32LE(342), + Version: bextBuffer.readUInt16LE(346), + UMID: bextBuffer.subarray(348, 380).toString('hex').trim(), + LoudnessValue: bextBuffer.readUInt16LE(380), + LoudnessRange: bextBuffer.readUInt16LE(382), + MaxTruePeakLevel: bextBuffer.readUInt16LE(384), + MaxMomentaryLoudness: bextBuffer.readUInt16LE(386), + MaxShortTermLoudness: bextBuffer.readUInt16LE(388) + }; + // bext data is UTF-8 encoded + const bextText = bextBuffer.subarray(392, chunkSize).toString('utf-8'); + const bextMetadata = _parseMetadataText(bextText); + metadata['bext'] = {...bext, ...bextMetadata}; + // Strip empty or null keys + for (let key in metadata['bext']) { + if (['', 0].includes(metadata.bext[key]) + || /^0*$/.test(metadata.bext[key]) + || /^\u0000*$/.test(metadata.bext[key])) { + delete metadata.bext[key]; + } + } + }); + } + if (chunkSize % 2 !== 0) currentOffset += 1; + currentOffset += chunkSize + readNextChunk(); // Continue reading after skipping the data chunk + }); + } + // Start reading chunks after the RIFF header + readNextChunk(); + }); + }); + }); +} + + +/** + * Helper function to parse GUANO text into key-value pairs + * @param {string} guanoText - GUANO text data + * @returns {object} Parsed GUANO metadata + */ +function _parseMetadataText(text) { + const metadata = {}; + // According to the GUANO Spec, the note field can contain escaped newline characters '\\n' + // So, we'll substitute a placeholder to avoid conflicts + const _tempGuano = text.replaceAll('\\n', '\uFFFF'); + const lines = _tempGuano.split('\n'); + + lines.forEach(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex !== -1) { + const key = line.slice(0, colonIndex).trim(); + // Replace the placeholder with '\n' + const value = line.slice(colonIndex + 1).trim().replaceAll('\uFFFF', '\n'); + + try { + // Attempt to parse JSON-like values + if ((value.startsWith('[') && value.endsWith(']')) || + (value.startsWith('{') && value.endsWith('}'))) { + metadata[key] = JSON.parse(value); + } else { + metadata[key] = value; + } + } catch { + metadata[key] = value; + } + } + }); + + return metadata; +} + +export {extractWaveMetadata} \ No newline at end of file diff --git a/js/ui.js b/js/ui.js index 45ec357d..16545d81 100644 --- a/js/ui.js +++ b/js/ui.js @@ -54,7 +54,7 @@ window.addEventListener('rejectionhandled', function(event) { }); let STATE = { - guano: {}, + metadata: {}, mode: 'analyse', analysisDone: false, openFiles: [], @@ -70,7 +70,7 @@ let STATE = { sortOrder: 'timestamp', birdList: { lastSelectedSpecies: undefined }, // Used to put the last selected species at the top of the all-species list selection: { start: undefined, end: undefined }, - currentAnalysis: {file: null, openFiles: [], mode: null, species: null, offset: 0, active: null} + currentAnalysis: {currentFile: null, openFiles: [], mode: null, species: null, offset: 0, active: null} } // Batch size map for slider @@ -231,6 +231,7 @@ const DOM = { get resultTable() {return document.getElementById('resultTableBody')}, get tooltip() { return document.getElementById('tooltip')}, get waveElement() { return document.getElementById('waveform')}, + get summary() { return document.getElementById('summary')}, get specElement() { return document.getElementById('spectrogram')}, get specCanvasElement() { return document.querySelector('#spectrogram canvas')}, get waveCanvasElement() { return document.querySelector('#waveform canvas')}, @@ -309,8 +310,7 @@ DIAGNOSTICS['System Memory'] = (os.totalmem() / (1024 ** 2 * 1000)).toFixed(0) + function resetResults({clearSummary = true, clearPagination = true, clearResults = true} = {}) { if (clearSummary) summaryTable.textContent = ''; - - clearPagination && pagination.forEach(item => item.classList.add('d-none')); + if (clearPagination) pagination.forEach(item => item.classList.add('d-none')); resultsBuffer = DOM.resultTable.cloneNode(false) if (clearResults) { DOM.resultTable.textContent = ''; @@ -360,11 +360,16 @@ function loadAudioFile({ filePath = '', preserveResults = false }) { function updateSpec({ buffer, play = false, position = 0, resetSpec = false }) { - showElement(['spectrogramWrapper'], false); - wavesurfer.loadDecodedBuffer(buffer); + if (resetSpec || DOM.spectrogramWrapper.classList.contains('d-none')){ + DOM.spectrogramWrapper.classList.remove('d-none'); + wavesurfer.loadDecodedBuffer(buffer); + adjustSpecDims(true); + } else { + wavesurfer.loadDecodedBuffer(buffer); + } wavesurfer.seekTo(position); play ? wavesurfer.play() : wavesurfer.pause(); - resetSpec && adjustSpecDims(true); + } function createTimeline() { @@ -694,36 +699,40 @@ function extractFileNameAndFolder(path) { * @returns {string} - HTML string of the formatted Bootstrap table */ function formatAsBootstrapTable(jsonData) { - let tableHtml = "
"; - - // Iterate over the key-value pairs in the JSON object - for (const [key, value] of Object.entries(JSON.parse(jsonData))) { - tableHtml += ''; - tableHtml += ``; - - // Check if the value is an object or array, if so, stringify it - if (typeof value === 'object') { - tableHtml += ``; - } else { - tableHtml += ``; + let parsedData = JSON.parse(jsonData); + let tableHtml = "
KeyValue
${key}${JSON.stringify(value, null, 2)}${value}
"; + + for (const [key, value] of Object.entries(keyValuePairs)) { + tableHtml += ''; + tableHtml += ``; + if (typeof value === 'object') { + tableHtml += ``; + } else { + tableHtml += ``; + } + tableHtml += ''; } - tableHtml += ''; + tableHtml += '
KeyValue
${key}${JSON.stringify(value, null, 2)}${value}
'; } - tableHtml += '
'; - return tableHtml; + tableHtml += '
'; + 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'),