Skip to content

Commit

Permalink
Merge pull request #1064 from kitzeslab/feat_914_sync
Browse files Browse the repository at this point in the history
Add audiomoth GPS sync tools and refactor localization into modules
  • Loading branch information
sammlapp authored Oct 8, 2024
2 parents 6a9e905 + 2ced2da commit 95ee5de
Show file tree
Hide file tree
Showing 19 changed files with 2,665 additions and 1,714 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ docs/tutorials/rana_sierrae_2022
lightning_logs/
docs/tutorials/*.ckpt
docs/tutorials/BirdNET*
docs/tutorials/*.WAV
docs/tutorials/*.csv

528 changes: 298 additions & 230 deletions docs/tutorials/acoustic_localization.ipynb

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions docs/tutorials/synchronize_audiomoth_gps_recordings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
sync all audio from an entire dataset by using the PPS data to generate audio files starting at a
known time and having the exact desired sampling rate creates a table with records of per-file
success or failure and saves each resampled, syncrhonized audio file
"""

from pathlib import Path
from tqdm import tqdm
import pandas as pd
import concurrent.futures

from opensoundscape import Audio
from opensoundscape.localization.audiomoth_sync import (
correct_sample_rate,
associate_pps_samples_timestamps,
)

## parameters to modify ##

output_sr = 24000 # desired final sample rate for all resampled audio
# path containing sub-folders with audio and metadata files
audio_root = Path("/path/to/all/audio/folders")
# path to save synchronized resampled audio files to
output_folder = Path("/path/to/save")

# find all folders in the audio root, where folders contain .WAV and .CSV files
# assumes that each WAV file has a matching .CSV file of the same name
# change this line to correctly find your audio folders.
# In this case, we are looking for folders in the audio_root directory.
audio_folders = list(audio_root.glob("*"))
print(f"Found {len(audio_folders)} folders in audio_root.")

cpu_workers = 4 # number of parallel processes to use

# compatability with older GPS firmwares such as AudioMothGPSDeploy_1_0_8_Hardware_1_1
PPS_SUFFIX = ".CSV" # use .PPS for older custom firmware, which saved files as .PPS
cpu_clock_counter_col = "TIMER_COUNT" # use "COUNTER" for older firmware

# skip completed files or repeat?
# if True, if output file exists, skips
# set to False to overwrite outputs
skip_if_completed = True

# raise or catch & log errors?
raise_exceptions = False

## utilities (probably do not modify) ##


def sync_entire_folder(folder):
"""find all .WAV and corresponding .CSV files in a folder, synchronize, and save to output_folder"""
audio_files = list(folder.glob("*.WAV"))
success_record = []

# make the directory for each sd card in the output folder
out_sd = output_folder / folder.stem
out_sd.mkdir(exist_ok=True, parents=True)
print(f"There are {len(audio_files)} files in {folder}")

for file in audio_files:
# check if already completed
out_filename = out_sd / file.name
if out_filename.exists() and skip_if_completed:
success_record.append(True)
continue
try:
# Get the processed PPS DF
pps_file = file.parent / str(file.stem + PPS_SUFFIX)
assert pps_file.exists()

processed_pps_df = associate_pps_samples_timestamps(
pps_file, cpu_clock_counter_col=cpu_clock_counter_col
)

# Resample the audio
resampled_audio = correct_sample_rate(
Audio.from_file(file), processed_pps_df, desired_sr=output_sr
)

# save
processed_pps_df.to_csv(out_sd / file.with_suffix(".csv").name)
resampled_audio.save(out_filename)
success_record.append(True)
except Exception as exc:
if raise_exceptions:
raise exc
print(f"Exception was raised while attempting to resample/sync {file}:")
print(exc)
success_record.append(False)

return pd.DataFrame(
zip(audio_files, success_record), columns=["audio_file", "success"]
)


if __name__ == "__main__":
"""treat each folder as a separate task, parallelize each task with CPU workers"""
with concurrent.futures.ProcessPoolExecutor(max_workers=cpu_workers) as executor:
sd_folders = tqdm(audio_folders)
all_success_records = list(executor.map(sync_entire_folder, sd_folders))

pd.concat(all_success_records).to_csv(
output_folder / Path("sync_success_fail_record.csv")
)
2 changes: 1 addition & 1 deletion opensoundscape/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from . import audio
from . import data_selection
from . import utils
from . import localization
from . import metrics
from . import ribbit
from . import sample
Expand All @@ -13,6 +12,7 @@
from . import ml
from . import preprocess
from . import logging
from . import localization

# expose some modules at the top level
from .ml import cnn, bioacoustics_model_zoo, cnn_architectures
Expand Down
20 changes: 13 additions & 7 deletions opensoundscape/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,9 +517,10 @@ def to_raven_files(self, save_dir, audio_files=None):
"""
)

# make list of unique files, while retaining order
# we will create one selection table for each file
# this list may contain NaN, which we handle below
unique_files = list(set(audio_files))
unique_files = [] if audio_files is None else unique(audio_files)

# If file names are not unique, raise an Exception
# otherwise, multiple selection table files with the same name would be
Expand Down Expand Up @@ -1071,7 +1072,8 @@ class names for each clip; also returns a second value, the list of class names
else:
audio_files = self.audio_files

audio_files = list(set(audio_files)) # remove duplicates
# make unique list, retain order
audio_files = unique(audio_files)

# generate list of start and end times for each clip
# if user passes None for full_duration, try to get the duration from each audio file
Expand Down Expand Up @@ -1237,7 +1239,7 @@ def categorical_to_multi_hot(labels, classes=None, sparse=False):
class_subset: list of classes corresponding to columns in the array
"""
if classes is None:
classes = list(set(itertools.chain(*labels)))
classes = unique(itertools.chain(*labels)) # retain order

label_idx_dict = {l: i for i, l in enumerate(classes)}
vals = []
Expand Down Expand Up @@ -1471,10 +1473,9 @@ def __init__(
multihot_df_dense: dense dataframe of multi-hot labels
"""
# labels can be list of lists of class names or list of lists of integer class indices
if (
classes is None
): # if classes are not provided, infer them from unique set of labels
classes = list(set(chain(*labels)))
# if classes are not provided, infer them from unique set of labels
if classes is None:
classes = unique(chain(*labels))
self.classes = list(classes)
# convert from lists of string class names to lists of integer class indices
if (
Expand Down Expand Up @@ -1600,3 +1601,8 @@ def _get_multiindex(self):
list(zip(self.df["file"], self.df["start_time"], self.df["end_time"])),
names=["file", "start_time", "end_time"],
)


def unique(x):
"""return unique elements of a list, retaining order"""
return list(dict.fromkeys(x))
34 changes: 28 additions & 6 deletions opensoundscape/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,10 @@ def from_file(
is not full contained within the audio file
Example of creating localized timestamp:
```
import pytz; from datetime import datetime;
{import pytz; from datetime import datetime;
local_timestamp = datetime(2020,12,25,23,59,59)
local_timezone = pytz.timezone('US/Eastern')
timestamp = local_timezone.localize(local_timestamp)
timestamp = local_timezone.localize(local_timestamp)}
```
out_of_bounds_mode:
- 'warn': generate a warning [default]
Expand Down Expand Up @@ -491,15 +491,23 @@ def trim_samples(self, start_sample, end_sample, out_of_bounds_mode="ignore"):
)

def trim_with_timestamps(
self, start_timestamp, end_timestamp, out_of_bounds_mode="warn"
self,
start_timestamp,
end_timestamp=None,
duration=None,
out_of_bounds_mode="warn",
):
"""Trim Audio object by localized datetime.datetime timestamps
requires that .metadata['recording_start_time'] is a localized datetime.datetime object
Args:
start_timestamp: localized datetime.datetime object for start of extracted clip
e.g. datetime(2020,4,4,10,25,15,tzinfo=pytz.UTC)
end_timestamp: localized datetime.datetime object for end of extracted clip
e.g. datetime(2020,4,4,10,25,20,tzinfo=pytz.UTC)
duration: duration in seconds of the extracted clip
- specify exactly one of duration or end_datetime
out_of_bounds_mode: behavior if requested time period is not fully contained
within the audio file. Options:
- 'ignore': return any available audio with no warning/error [default]
Expand All @@ -510,6 +518,10 @@ def trim_with_timestamps(
a new Audio object containing samples from start_timestamp to end_timestamp
- metadata is updated to reflect new start time and duration
"""
# user must past either end_datetime or duration
if end_timestamp is None and duration is None:
raise ValueError("Must specify either end_datetime or duration")

if "recording_start_time" not in self.metadata:
raise ValueError(
"metadata must contain 'recording_start_time' to use trim_with_timestamps"
Expand All @@ -518,21 +530,31 @@ def trim_with_timestamps(
assert isinstance(
self.metadata["recording_start_time"], datetime.datetime
), "metadata['recording_start_time'] must be a datetime.datetime object"

# if duration is specified, calculate end_datetime by adding duration to start_datetime
if duration is not None:
assert (
end_timestamp is None
), "do not specify both duration and end_datetime"
end_timestamp = start_timestamp + datetime.timedelta(seconds=duration)

assert isinstance(start_timestamp, datetime.datetime) and isinstance(
end_timestamp, datetime.datetime
), "start_timestamp and end_timestamp must be localized datetime.datetime objects"
assert (
start_timestamp.tzinfo is not None and end_timestamp.tzinfo is not None
), "start_timestamp and end_timestamp must be localized datetime.datetime objects, but tzinfo is None"

start_time = (
start_seconds = (
start_timestamp - self.metadata["recording_start_time"]
).total_seconds()
end_time = (
end_seconds = (
end_timestamp - self.metadata["recording_start_time"]
).total_seconds()

return self.trim(start_time, end_time, out_of_bounds_mode=out_of_bounds_mode)
return self.trim(
start_seconds, end_seconds, out_of_bounds_mode=out_of_bounds_mode
)

def loop(self, length=None, n=None):
"""Extend audio file by looping it
Expand Down
Loading

0 comments on commit 95ee5de

Please sign in to comment.