Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- `overviewMode: 'spectrogram'` (and `player.setOverviewMode()`): the overview
strip shows the whole recording as a spectrogram, max-pooled so short calls
stay visible; the waveform stands in while the spectrogram is computed.
The labeling app offers the choice under Display → Overview.
- Recording start time from AudioMoth, GUANO, BWF `bext` and WAV INFO metadata
or a date and time in the file name (adapted from the BirdNET website demo,
MIT): shown in the status bar, `player.recordingStart`,
`player.clockTimeAt(seconds)`, and Raven selection tables get
Begin Date / Begin Clock Time / End Clock Time.
- Microphone recording: `player.startRecording()` / `stopRecording()` show a
live spectrogram that flies in from the right while recording, then load
the recording (a WAV with its start time in the INFO chunk). Events
`recordingstart`, `recordingcomplete`, `recordingstop`. The labeling app
adds "Record from microphone" under Add recordings.

## [1.1.3] - 2026-11-14

### Added
Expand Down
69 changes: 64 additions & 5 deletions demo/labeling-app.html
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,9 @@ <h3>Recording not found</h3>
<button class="project-add-file" id="browseBtn" type="button" title="Add a local audio file (needs an XC number only if you upload it to Xeno-canto)">
<span aria-hidden="true">+</span> Local audio file…
</button>
<button class="project-add-file" id="recordBtn" type="button" hidden title="Record from the microphone; the spectrogram shows the sound as it arrives (up to 5 minutes)">
<span aria-hidden="true">●</span> <span class="record-btn-text">Record from microphone</span>
</button>
</div>
<!-- Recording explorer — populated by RecordingExplorer widget -->
<div id="recordingExplorerMount"></div>
Expand Down Expand Up @@ -820,8 +823,9 @@ <h3>Tags</h3>
}

// ── Player ────────────────────────────────────────────────────────
// Waveform above the spectrogram is optional (the overview below always shows one); applied on load
// Waveform above the spectrogram is optional; applied on load. The overview below shows a waveform or the whole spectrogram.
const SHOW_WAVEFORM_KEY = 'signavis.labeling.show-waveform.v1';
const OVERVIEW_MODE_KEY = 'signavis.labeling.overview-mode.v1';
const SHOW_CONF_KEY = 'signavis.labeling.show-confidence.v1';
const AUTO_FREQ_VIEW_KEY = 'signavis.labeling.auto-freq-view.v1';
const NORMALIZE_KEY = 'signavis.labeling.normalize.v1';
Expand All @@ -834,6 +838,7 @@ <h3>Tags</h3>
showFileOpen: false,
enableProgressiveSpectrogram: true,
showOverview: true,
overviewMode: (() => { try { return localStorage.getItem(OVERVIEW_MODE_KEY) === 'spectrogram' ? 'spectrogram' : 'waveform'; } catch { return 'waveform'; } })(),
viewMode: showMainWaveform ? 'both' : 'spectrogram',
showTransport: true,
// BirdNET detections (no frequency bounds) as strips along the top edge
Expand Down Expand Up @@ -1383,6 +1388,23 @@ <h3>Tags</h3>
});
specPanel.el.insertBefore(wf, sec);

const ov = document.createElement('div');
ov.className = 'settings-section';
ov.style.cssText = 'padding:8px 14px;border-top:1px solid var(--color-border)';
ov.innerHTML = `
<label class="field-label" for="overviewModeSelect">Overview</label>
<select id="overviewModeSelect" class="field-select" title="What the overview strip below the player shows. Spectrogram: the whole recording, with short calls kept visible.">
<option value="waveform">Waveform</option>
<option value="spectrogram">Spectrogram</option>
</select>`;
const ovSel = ov.querySelector('select');
ovSel.value = player.overviewMode;
ovSel.addEventListener('change', () => {
try { localStorage.setItem(OVERVIEW_MODE_KEY, ovSel.value); } catch { /* ignore */ }
player.setOverviewMode(ovSel.value);
});
specPanel.el.insertBefore(ov, sec);

// ── Automatic behaviour: what the app does by itself, each one switchable ──
const auto = document.createElement('div');
auto.className = 'settings-section';
Expand Down Expand Up @@ -2212,7 +2234,7 @@ <h3>Tags</h3>
function downloadRavenTable() {
const entry = recordingManager.getActive();
const name = entry?.filename || fileLabel.textContent || 'labels';
const text = buildRavenSelectionTable(state.labels, { sampleRate: entry?.sampleRate || state.sampleRate, setName: ravenSetName });
const text = buildRavenSelectionTable(state.labels, { sampleRate: entry?.sampleRate || state.sampleRate, setName: ravenSetName, recordingStart: entry?.recordingStart || player.recordingStart });
saveFile(text, ravenTablePath(name.split('/').pop()), 'text/plain')
.catch((e) => { selectionMeta.textContent = `Export failed: ${e?.message || e}`; });
}
Expand All @@ -2225,7 +2247,7 @@ <h3>Tags</h3>
const labels = entryLabels(entry);
if (!labels.length) continue;
const target = entry.path ? ravenTablePath(entry.path) : `exports/raven/${ravenTablePath(entry.filename)}`;
await projectStore.writeText(target, buildRavenSelectionTable(labels, { sampleRate: entry.sampleRate, setName: ravenSetName }));
await projectStore.writeText(target, buildRavenSelectionTable(labels, { sampleRate: entry.sampleRate, setName: ravenSetName, recordingStart: entry.recordingStart || null }));
written++;
}
selectionMeta.textContent = written
Expand Down Expand Up @@ -2597,7 +2619,13 @@ <h3>Tags</h3>
// Blob URL (local file) — same origin, no CORS issue
_lastAudioUrl = entry.audioUrl?.startsWith('blob:') ? entry.audioUrl : null;
player.showLoadingOverlay('Loading…');
await player.loadUrl(entry.audioUrl);
if (_lastAudioUrl) {
// As a File with its name, so a start time in the file name is found again.
const blob = await (await fetch(entry.audioUrl)).blob();
await player.loadFile(new File([blob], (entry.filename || 'audio').split('/').pop(), { type: blob.type }));
} else {
await player.loadUrl(entry.audioUrl);
}
}
} catch (e) {
_audioFailed = true;
Expand Down Expand Up @@ -3236,6 +3264,7 @@ <h3>Open another recording</h3>
const out = buildLabelExport(format, labels, base, {
sampleRate: entry?.sampleRate || state.sampleRate,
setName: ravenSetName,
recordingStart: entry?.recordingStart || player.recordingStart,
columns,
displayName: (l) => resolveName(l).display,
});
Expand Down Expand Up @@ -4276,6 +4305,31 @@ <h3>Open another recording</h3>
catch (e) { showLoadError(e?.message || e, 'Cannot open'); return; }
if (picked) await loadLocalAudioFile(picked.file, { filePath: picked.path });
});
// ── Microphone recording: live spectrogram, then added like a local file ──
const recordBtn = document.getElementById('recordBtn');
if (recordBtn && BirdNETPlayer.recordingSupported) {
recordBtn.hidden = false;
const recordText = recordBtn.querySelector('.record-btn-text');
const setRecording = (on) => {
recordBtn.classList.toggle('is-recording', on);
recordText.textContent = on ? 'Stop recording' : 'Record from microphone';
};
recordBtn.addEventListener('click', async () => {
if (player.isRecording) { await player.stopRecording(); return; }
try {
await player.startRecording({ maxSeconds: 300, loadWhenDone: false });
setRecording(true);
} catch (e) {
showLoadError(e?.name === 'NotAllowedError' ? 'Microphone access was denied.' : (e?.message || e), 'Cannot record');
}
});
player.on('recordingstop', () => setRecording(false));
player.on('recordingcomplete', async (e) => {
setRecording(false);
const file = e.detail?.file;
if (file) await loadLocalAudioFile(file);
});
}
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
Expand Down Expand Up @@ -4331,6 +4385,7 @@ <h3>Open another recording</h3>
audioUrl: _blobUrl,
isBlobUrl: true,
...extra,
recordingStart: player.recordingStart || null,
});
recordingManager.setActive(_fileEntry.id);
state._labelsEntryId = _fileEntry.id; // empty labels now belong to the new entry
Expand Down Expand Up @@ -4414,7 +4469,11 @@ <h3>Open another recording</h3>
setTimeout(() => requestMissingSnr(), 500);
if (e.detail?.sampleRate) {
state.sampleRate = e.detail.sampleRate;
if (recordingManager?.activeId) recordingManager.update(recordingManager.activeId, { sampleRate: e.detail.sampleRate });
if (recordingManager?.activeId) {
// Keep a known start when this load could not tell (e.g. metadata stripped).
const known = recordingManager.getById(recordingManager.activeId)?.recordingStart || null;
recordingManager.update(recordingManager.activeId, { sampleRate: e.detail.sampleRate, recordingStart: player.recordingStart || known });
}
}
propsPanel.setRecordingMeta(state.recordingMeta, {
sampleRate: state.sampleRate,
Expand Down
28 changes: 28 additions & 0 deletions docs/usage/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,34 @@ input.addEventListener('change', async () => {
})
```

## Record from the microphone

While recording, the spectrogram pane shows the sound as it arrives (it flies
in from the right, 20 s across). When it stops — Stop button, `stopRecording()`
or after `maxSeconds` — the recording is loaded like a file. The WAV carries
its start time, so `player.recordingStart` knows the clock time (the labeling app's
Raven selection tables use it; `player.exportAnnotationsRaven()` has no clock columns).

```js
if (BirdNETPlayer.recordingSupported) {
player.on('recordingcomplete', ({ detail }) => {
console.log(detail.file.name, detail.duration) // Recording_20261115_143012.wav
})
await player.startRecording({ maxSeconds: 300 }) // asks for microphone access
// … later
await player.stopRecording()
}
```

Pass `loadWhenDone: false` to handle `detail.file` yourself.

## Recording start time

`player.recordingStart` is read from AudioMoth, GUANO, BWF `bext` or WAV INFO
metadata, or from a date and time in the file name (`20230512_190000.wav`);
`null` when unknown. `player.clockTimeAt(seconds)` gives the wall-clock time at
a position, e.g. `"2023-05-12T19:00:03.000+01:00"`.

## React

```jsx
Expand Down
1 change: 1 addition & 0 deletions docs/usage/player-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
| `showDisplayGain` | boolean | `true` | Show floor/ceiling sliders, auto contrast |
| `showStatusbar` | boolean | `true` | Show bottom status bar |
| `showOverview` | boolean | `true` | Show overview navigator |
| `overviewMode` | string | `'waveform'` | `'waveform'` or `'spectrogram'` — the whole recording as a max-pooled spectrogram (short calls stay visible); switch at runtime with `player.setOverviewMode(mode)` |
| `showWaveformTimeline` | boolean | `true` | Show bottom timeline in waveform view |
| `compactToolbar` | string | `'auto'` | `'auto'`, `'on'`, `'off'` — responsive toolbar compaction |
| `labelTaxonomy` | array | see docs | Custom label presets (name, color, shortcut) |
Expand Down
1 change: 1 addition & 0 deletions python-wrapper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ HTML(html)
| `viewMode` | `'spectrogram'` | Show only the spectrogram |
| `transportStyle` | `'hero'` | Large centered play button |
| `showOverview` | `False` | Hide overview navigator |
| `overviewMode` | `'spectrogram'` | Overview shows the whole spectrogram instead of the waveform |
| `showFileOpen` | `False` | Hide file open button |
| `showStatusbar` | `False` | Hide status bar |
| `labelTaxonomy` | `[{'name': 'Species', 'color': '#0ea5e9'}]` | Custom label presets |
Expand Down
95 changes: 95 additions & 0 deletions src/app/BirdNETPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ export {
importXenoCantoSpectrogramLabels,
} from '../infrastructure/xeno-canto/xenoCantoRecordingsApi.ts';
export { TaxonomyResolver } from '../infrastructure/taxonomyResolver.ts';
import { isoClockTime, type RecordingStart } from '../domain/recordingTime.ts';
import { LiveRecorder, type LiveRecorderOptions, type LiveRecording } from '../infrastructure/audio/LiveRecorder.ts';
import { LiveSpectrogramView } from '../ui/components/live-view/live-spectrogram-view.ts';
export { LiveRecorder, encodeWav, recordingFileName, type LiveRecorderOptions, type LiveRecording } from '../infrastructure/audio/LiveRecorder.ts';

export interface StartRecordingOptions extends LiveRecorderOptions {
/** Load the recording into the player when it stops (default true). */
loadWhenDone?: boolean;
/** Seconds across the live view (default 20). */
spanSeconds?: number;
}
export { readRecordingStart, isoClockTime, ravenClock, utcLabel, formatRecordingStart, type RecordingStart } from '../domain/recordingTime.ts';
export { BirdNETInference, BIRDNET_MODEL_URL } from '../infrastructure/birdnetInference.ts';

export class BirdNETPlayer {
Expand Down Expand Up @@ -714,6 +726,8 @@ export class BirdNETPlayer {

/** Tear down the player and free resources */
destroy() {
this._liveRecorder?.cancel();
this._liveView?.detach();
this._labelGrid?.destroy();
this._labelGrid = null;
if (this._globalKeyHandler) {
Expand Down Expand Up @@ -962,6 +976,87 @@ export class BirdNETPlayer {

get normalize() { return !!this._state?.normalize; }

/**
* When the loaded recording started, from AudioMoth / GUANO / BWF / WAV INFO
* metadata or a date and time in the file name; null when unknown.
* `ms` is the recorder's wall-clock time written as UTC, `tz` its offset in minutes.
*/
get recordingStart(): RecordingStart | null { return this._state?.recordingStart ?? null; }

/** Wall-clock time at `seconds` into the recording ("2023-05-12T19:00:03.000+01:00"), null when the start is unknown. */
clockTimeAt(seconds: number): string | null {
const start = this.recordingStart;
return start ? isoClockTime(start, seconds) : null;
}

// ── Live recording ──────────────────────────────────────────────

_liveRecorder: LiveRecorder | null = null;
_liveView: LiveSpectrogramView | null = null;

/** True when this browser can record from a microphone. */
static get recordingSupported(): boolean { return LiveRecorder.supported; }

get isRecording(): boolean { return !!this._liveRecorder?.recording; }

/**
* Records from the microphone; the spectrogram pane shows the sound as it
* arrives. Emits 'recordingstart', then 'recordingcomplete' { file,
* startedAt, duration, sampleRate, reason } (or 'recordingstop' { reason }
* without a recording). Stops by itself after `maxSeconds` (default 300).
* The WAV carries the start time, so `recordingStart` knows it after loading.
*/
async startRecording(opts: StartRecordingOptions = {}): Promise<void> {
if (this._liveRecorder) throw new Error('Already recording');
const s = this._state;
if (!s) throw new Error('Player not ready');
this.pause();
const rec = new LiveRecorder(opts);
this._liveRecorder = rec;
const host = (s.d.spectrogramContainer && s._showSpectrogram ? s.d.spectrogramContainer : this.container) as HTMLElement;
const view = new LiveSpectrogramView(host, {
spanSeconds: opts.spanSeconds,
getColorScheme: () => s.d.colorSchemeSelect?.value || 'inferno',
getMaxFreq: () => parseFloat(s.d.maxFreqSelect?.value || '0') || 0,
});
view.addEventListener('stoprequest', () => { void this.stopRecording(); });
let reason: 'user' | 'limit' | 'ended' = 'user';
rec.addEventListener('stop', (e: Event) => { reason = (e as CustomEvent).detail?.reason ?? 'user'; });
const finished = new Promise<LiveRecording | null>((resolve) => {
rec.addEventListener('stop', () => resolve(rec.stop()), { once: true });
});
try {
await rec.start();
} catch (err) {
this._liveRecorder = null;
throw err;
}
this._liveView = view;
view.attach(rec);
this._emit('recordingstart', { sampleRate: rec.sampleRate, maxSeconds: rec.maxSeconds });
void finished.then(async (recording) => {
view.detach();
if (this._liveView === view) this._liveView = null;
if (this._liveRecorder === rec) this._liveRecorder = null;
if (!recording) { this._emit('recordingstop', { reason }); return; }
this._emit('recordingcomplete', { ...recording, reason });
if (opts.loadWhenDone !== false) await this.loadFile(recording.file).catch(() => {});
});
}

/** Stops the recording; resolves with it (null when nothing was recorded or none was running). */
async stopRecording(): Promise<LiveRecording | null> {
return this._liveRecorder ? this._liveRecorder.stop('user') : null;
}

/** Stops the recording and throws it away. */
cancelRecording(): void { this._liveRecorder?.cancel(); }

/** Overview strip below the player: 'waveform' or a whole-file 'spectrogram'. */
setOverviewMode(mode: 'waveform' | 'spectrogram') { this._state?._setOverviewMode(mode); }

get overviewMode(): 'waveform' | 'spectrogram' { return this._state?._overviewMode ?? 'waveform'; }

// ── Built-in label table ────────────────────────────────────────

_labelGrid: LabelGrid | null = null;
Expand Down
Loading
Loading