Skip to content

Commit 928bf9f

Browse files
authored
fixed timestamps not respecting overlap when using different audio speed (#722)
* fixed timestamps not respecting overlap when using different audio speed * fixed offset overtaking start timestamp * slight change because of floats * speed up and overlap tests * slow down test * parameterized test cases for overlap + speed * .
1 parent 2115f8c commit 928bf9f

4 files changed

Lines changed: 120 additions & 44 deletions

File tree

birdnet_analyzer/analyze/core.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,21 @@ def _set_params(
160160
from birdnet_analyzer.species.utils import get_species_list
161161
from birdnet_analyzer.utils import collect_audio_files, read_lines
162162

163+
if not isinstance(overlap, int | float):
164+
raise ValueError("Overlap must be a numeric value.")
165+
166+
if overlap < 0:
167+
raise ValueError("Overlap must be a non-negative value.")
168+
169+
if overlap >= cfg.SIG_LENGTH:
170+
raise ValueError(f"Overlap must be less than {cfg.SIG_LENGTH} seconds.")
171+
172+
if not isinstance(audio_speed, int | float):
173+
raise ValueError("Audio speed must be a numeric value.")
174+
175+
if audio_speed <= 0:
176+
raise ValueError("Audio speed must be a positive value.")
177+
163178
cfg.CODES = load_codes()
164179
cfg.LABELS = read_lines(labels_file if labels_file else cfg.LABELS_FILE)
165180
cfg.SKIP_EXISTING_RESULTS = skip_existing_results

birdnet_analyzer/analyze/utils.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -600,9 +600,8 @@ def analyze_file(item) -> dict[str, str] | None:
600600

601601
# Start time
602602
start_time = datetime.datetime.now()
603-
offset = 0
604603
duration = int(cfg.FILE_SPLITTING_DURATION / cfg.AUDIO_SPEED)
605-
start, end = 0, cfg.SIG_LENGTH
604+
start, end = 0, cfg.SIG_LENGTH * cfg.AUDIO_SPEED
606605
results = {}
607606

608607
# Status
@@ -619,19 +618,19 @@ def analyze_file(item) -> dict[str, str] | None:
619618

620619
# Process each chunk
621620
try:
622-
while offset < fileLengthSeconds:
623-
chunks = get_raw_audio_from_file(fpath, offset, duration)
621+
while start < fileLengthSeconds and not np.isclose(start, fileLengthSeconds):
622+
chunks = get_raw_audio_from_file(fpath, start, duration)
624623
samples = []
625624
timestamps = []
626625

627626
for chunk_index, chunk in enumerate(chunks):
628627
# Add to batch
629628
samples.append(chunk)
630-
timestamps.append([round(start * cfg.AUDIO_SPEED, 1), round(end * cfg.AUDIO_SPEED, 1)])
629+
timestamps.append([round(start, 1), round(end, 1)])
631630

632631
# Advance start and end
633-
start += cfg.SIG_LENGTH - cfg.SIG_OVERLAP
634-
end = start + cfg.SIG_LENGTH
632+
start += (cfg.SIG_LENGTH - cfg.SIG_OVERLAP) * cfg.AUDIO_SPEED
633+
end = min(start + cfg.SIG_LENGTH * cfg.AUDIO_SPEED, fileLengthSeconds)
635634

636635
# Check if batch is full or last chunk
637636
if len(samples) < cfg.BATCH_SIZE and chunk_index < len(chunks) - 1:
@@ -671,7 +670,6 @@ def analyze_file(item) -> dict[str, str] | None:
671670
# Clear batch
672671
samples = []
673672
timestamps = []
674-
offset = offset + duration
675673

676674
except Exception as ex:
677675
# Write error log

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ gui = [
4545
embeddings = ["perch-hoplite"]
4646
all = ["birdnet-analyzer[server,gui]"]
4747
docs = ["sphinx", "sphinx-rtd-theme", "sphinx-argparse"]
48-
tests = ["pytest"]
48+
tests = ["pytest", "pytest-timeout"]
4949
dev = ["birdnet_analyzer[tests]", "birdnet_analyzer[docs]", "ruff"]
5050

5151
[project.scripts]
@@ -93,6 +93,7 @@ birdnet_analyzer = [
9393
[tool.pytest.ini_options]
9494
testpaths = ["tests"]
9595
pythonpath = ["birdnet_analyzer"]
96+
timeout = 120
9697

9798
[tool.ruff]
9899
exclude = ["conf.py"]

tests/analyze/test_analyze.py

Lines changed: 97 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import tempfile
55
from unittest.mock import MagicMock, patch
66

7-
import numpy as np
87
import pytest
98

109
import birdnet_analyzer.config as cfg
@@ -32,9 +31,7 @@ def setup_test_environment():
3231
f.write(b"more dummy audio data")
3332

3433
# Store original config values
35-
original_config = {
36-
attr: getattr(cfg, attr) for attr in dir(cfg) if not attr.startswith("_") and not callable(getattr(cfg, attr))
37-
}
34+
original_config = {attr: getattr(cfg, attr) for attr in dir(cfg) if not attr.startswith("_") and not callable(getattr(cfg, attr))}
3835

3936
yield {
4037
"test_dir": test_dir,
@@ -88,9 +85,7 @@ def test_analyze_single_file(
8885
@patch("birdnet_analyzer.analyze.core._set_params")
8986
@patch("multiprocessing.Pool")
9087
@patch("birdnet_analyzer.analyze.utils.save_analysis_params")
91-
def test_analyze_directory_multiprocess(
92-
mock_save_params: MagicMock, mock_pool, mock_set_params: MagicMock, mock_ensure_model: MagicMock, setup_test_environment
93-
):
88+
def test_analyze_directory_multiprocess(mock_save_params: MagicMock, mock_pool, mock_set_params: MagicMock, mock_ensure_model: MagicMock, setup_test_environment):
9489
"""Test analyzing multiple files with multiprocessing."""
9590
env = setup_test_environment
9691

@@ -180,6 +175,7 @@ def test_analyze_with_location_filtering(mock_analyze_file: MagicMock, mock_set_
180175

181176
# Verify parameter passing
182177
mock_set_params.assert_called_once()
178+
mock_ensure_model.assert_called_once()
183179
_, kwargs = mock_set_params.call_args
184180
assert kwargs["lat"] == 42.5
185181
assert kwargs["lon"] == -76.45
@@ -207,16 +203,15 @@ def test_analyze_with_custom_classifier(mock_analyze_file: MagicMock, mock_set_p
207203

208204
# Verify parameter passing
209205
mock_set_params.assert_called_once()
206+
mock_ensure_model.assert_called_once()
210207
_, kwargs = mock_set_params.call_args
211208
assert kwargs["custom_classifier"] == custom_classifier
212209

213210

214211
@patch("birdnet_analyzer.utils.ensure_model_exists")
215212
@patch("birdnet_analyzer.analyze.core._set_params")
216213
@patch("birdnet_analyzer.analyze.utils.analyze_file")
217-
def test_analyze_with_multiple_result_types(
218-
mock_analyze_file: MagicMock, mock_set_params: MagicMock, mock_ensure_model: MagicMock, setup_test_environment
219-
):
214+
def test_analyze_with_multiple_result_types(mock_analyze_file: MagicMock, mock_set_params: MagicMock, mock_ensure_model: MagicMock, setup_test_environment):
220215
"""Test analyzing with multiple output result types."""
221216
env = setup_test_environment
222217

@@ -229,16 +224,15 @@ def test_analyze_with_multiple_result_types(
229224

230225
# Verify parameter passing
231226
mock_set_params.assert_called_once()
227+
mock_ensure_model.assert_called_once()
232228
_, kwargs = mock_set_params.call_args
233229
assert kwargs["rtype"] == ["table", "csv", "audacity"]
234230

235231

236232
@patch("birdnet_analyzer.utils.ensure_model_exists")
237233
@patch("birdnet_analyzer.analyze.core._set_params")
238234
@patch("birdnet_analyzer.analyze.utils.analyze_file")
239-
def test_analyze_with_custom_species_list(
240-
mock_analyze_file: MagicMock, mock_set_params: MagicMock, mock_ensure_model: MagicMock, setup_test_environment
241-
):
235+
def test_analyze_with_custom_species_list(mock_analyze_file: MagicMock, mock_set_params: MagicMock, mock_ensure_model: MagicMock, setup_test_environment):
242236
"""Test analyzing with a custom species list."""
243237
env = setup_test_environment
244238

@@ -256,59 +250,126 @@ def test_analyze_with_custom_species_list(
256250

257251
# Verify parameter passing
258252
mock_set_params.assert_called_once()
253+
mock_ensure_model.assert_called_once()
259254
_, kwargs = mock_set_params.call_args
260255
assert kwargs["slist"] == species_list
261256

262-
def test_analyze_with_speed_up(setup_test_environment):
263-
"""Test analyzing with speed up."""
257+
@patch("birdnet_analyzer.utils.ensure_model_exists")
258+
def test_analyze_with_negative_speed(setup_test_environment):
259+
"""Test analyzing with negative speed."""
264260
env = setup_test_environment
265261

266262
soundscape_path = "birdnet_analyzer/example/soundscape.wav"
267263

268264
assert os.path.exists(soundscape_path), "Soundscape file does not exist"
269265

270266
# Call function under test
271-
analyze(soundscape_path, env["output_dir"], audio_speed=5.0, top_n=1, min_conf=0)
267+
with pytest.raises(ValueError, match="Audio speed must be a positive value."):
268+
analyze(soundscape_path, env["output_dir"], audio_speed=-1.0, top_n=1, min_conf=0)
272269

273-
output_file = os.path.join(env["output_dir"], "soundscape.BirdNET.selection.table.txt")
274-
assert os.path.exists(output_file)
270+
@patch("birdnet_analyzer.utils.ensure_model_exists")
271+
def test_analyze_with_zero_speed(setup_test_environment):
272+
"""Test analyzing with zero speed."""
273+
env = setup_test_environment
275274

276-
with open(output_file) as f:
277-
lines = f.readlines()[1:]
278-
assert len(lines) == 8, "Number of predicted segments does not match"
275+
soundscape_path = "birdnet_analyzer/example/soundscape.wav"
279276

280-
for index, line in enumerate(lines):
281-
parts = line.strip().split("\t")
282-
start = float(parts[3])
283-
end = float(parts[4])
284-
assert np.isclose(start, index * 15), "Start time does not match expected value"
285-
assert np.isclose(end, (index + 1) * 15), "End time does not match expected value"
277+
assert os.path.exists(soundscape_path), "Soundscape file does not exist"
278+
279+
# Call function under test
280+
with pytest.raises(ValueError, match="Audio speed must be a positive value."):
281+
analyze(soundscape_path, env["output_dir"], audio_speed=0.0, top_n=1, min_conf=0)
282+
283+
@patch("birdnet_analyzer.utils.ensure_model_exists")
284+
def test_analyze_with_invalid_audio_speed(setup_test_environment):
285+
"""Test analyzing with invalid audio speed."""
286+
env = setup_test_environment
287+
288+
soundscape_path = "birdnet_analyzer/example/soundscape.wav"
289+
290+
assert os.path.exists(soundscape_path), "Soundscape file does not exist"
291+
292+
# Call function under test
293+
with pytest.raises(ValueError, match="Audio speed must be a numeric value."):
294+
analyze(soundscape_path, env["output_dir"], audio_speed="fast", top_n=1, min_conf=0)
295+
296+
@patch("birdnet_analyzer.utils.ensure_model_exists")
297+
def test_analyze_with_negative_overlap(setup_test_environment):
298+
"""Test analyzing with invalid overlap."""
299+
env = setup_test_environment
286300

301+
soundscape_path = "birdnet_analyzer/example/soundscape.wav"
302+
303+
assert os.path.exists(soundscape_path), "Soundscape file does not exist"
287304

288-
def test_analyze_with_slow_down(setup_test_environment):
305+
# Call function under test
306+
with pytest.raises(ValueError, match="Overlap must be a non-negative value."):
307+
analyze(soundscape_path, env["output_dir"], audio_speed=1.0, top_n=1, overlap=-1)
308+
309+
@patch("birdnet_analyzer.utils.ensure_model_exists")
310+
def test_analyze_with_invalid_overlap(setup_test_environment):
311+
"""Test analyzing with invalid overlap."""
312+
env = setup_test_environment
313+
314+
soundscape_path = "birdnet_analyzer/example/soundscape.wav"
315+
316+
assert os.path.exists(soundscape_path), "Soundscape file does not exist"
317+
318+
# Call function under test
319+
with pytest.raises(ValueError, match="Overlap must be a numeric value."):
320+
analyze(soundscape_path, env["output_dir"], audio_speed=1.0, top_n=1, overlap="high")
321+
322+
@patch("birdnet_analyzer.utils.ensure_model_exists")
323+
def test_analyze_with_too_high_overlap(setup_test_environment):
324+
"""Test analyzing with too high overlap."""
325+
env = setup_test_environment
326+
327+
soundscape_path = "birdnet_analyzer/example/soundscape.wav"
328+
329+
assert os.path.exists(soundscape_path), "Soundscape file does not exist"
330+
331+
# Call function under test
332+
with pytest.raises(ValueError, match=f"Overlap must be less than {cfg.SIG_LENGTH} seconds."):
333+
analyze(soundscape_path, env["output_dir"], audio_speed=1.0, top_n=1, overlap=3.0)
334+
335+
@pytest.mark.parametrize(
336+
("audio_speed", "overlap"),
337+
[(10, 1), (5, 2), (5, 0), (0.1, 1), (0.2, 0)],
338+
)
339+
def test_analyze_with_speed_up_and_overlap(setup_test_environment, audio_speed, overlap):
289340
"""Test analyzing with speed up."""
290341
env = setup_test_environment
291342

292343
soundscape_path = "birdnet_analyzer/example/soundscape.wav"
293344

294345
assert os.path.exists(soundscape_path), "Soundscape file does not exist"
346+
file_length = 120
347+
step_size = round(3 * audio_speed - overlap * audio_speed, 1)
348+
expected_start_timestamps = [e / 10 for e in range(0, int(file_length * 10), int(step_size * 10))]
349+
expected_end_timestamps = [e / 10 for e in range(int(3 * audio_speed * 10), int(file_length) * 10 + 1, int(step_size * 10))]
350+
351+
while len(expected_end_timestamps) < len(expected_start_timestamps):
352+
if file_length - expected_start_timestamps[-1] >= 1 * audio_speed:
353+
expected_end_timestamps.append(file_length)
354+
else:
355+
expected_start_timestamps.pop()
295356

296357
# Call function under test
297-
analyze(soundscape_path, env["output_dir"], audio_speed=0.2, top_n=1, min_conf=0)
358+
analyze(soundscape_path, env["output_dir"], audio_speed=audio_speed, top_n=1, overlap=overlap)
298359

299360
output_file = os.path.join(env["output_dir"], "soundscape.BirdNET.selection.table.txt")
300361
assert os.path.exists(output_file)
301362

302363
with open(output_file) as f:
303364
lines = f.readlines()[1:]
304-
assert len(lines) == 200, "Number of predicted segments does not match"
305365

306-
for index, line in enumerate(lines):
366+
for expected_start, expected_end, line in zip(expected_start_timestamps, expected_end_timestamps, lines, strict=True):
307367
parts = line.strip().split("\t")
308-
start = float(parts[3])
309-
end = float(parts[4])
310-
assert np.isclose(start, index * 0.6), "Start time does not match expected value"
311-
assert np.isclose(end, (index + 1) * 0.6), "End time does not match expected value"
368+
actual_start = float(parts[3])
369+
actual_end = float(parts[4])
370+
assert float(actual_start) == expected_start, "Start time does not match expected value"
371+
assert float(actual_end) == expected_end, "End time does not match expected value"
372+
312373

313374
@patch("birdnet_analyzer.utils.ensure_model_exists")
314375
def test_analyze_with_additional_columns(mock_ensure_model, setup_test_environment):
@@ -332,6 +393,7 @@ def test_analyze_with_additional_columns(mock_ensure_model, setup_test_environme
332393
rtype=["csv"],
333394
)
334395

396+
mock_ensure_model.assert_called_once()
335397
output_file = os.path.join(env["output_dir"], "soundscape.BirdNET.results.csv")
336398
assert os.path.exists(output_file)
337399

0 commit comments

Comments
 (0)