Skip to content

Commit

Permalink
feat(analyzer): Add back hash duration option
Browse files Browse the repository at this point in the history
We can use the hash duration to skip hashes in the output returned by Chromaprint.
By default, it seems Chromaprint generates a hash for every 100ms of audio. Instead,
we can specify a default hash duration of 500ms and take 1/5 hashes. This both compresses
the frame hash data on disk _and_ significantly speeds up the search step.
  • Loading branch information
aksiksi committed Dec 24, 2022
1 parent fbbb36f commit 61065e1
Show file tree
Hide file tree
Showing 11 changed files with 104 additions and 48 deletions.
2 changes: 1 addition & 1 deletion needle-capi/examples/analyzer.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ int main() {

needle_audio_analyzer_print_paths(analyzer);

err = needle_audio_analyzer_run(analyzer, 0.3, 3.0, false, true);
err = needle_audio_analyzer_run(analyzer, 0.3, false, true);
if (err != 0) {
printf("Failed to run analyzer: %s\n", needle_error_to_str(err));
goto done;
Expand Down
2 changes: 1 addition & 1 deletion needle-capi/examples/full.c
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ int main() {
// Print analyzer paths.
needle_audio_analyzer_print_paths(analyzer);

err = needle_audio_analyzer_run(analyzer, 0.3, 3.0, false, true);
err = needle_audio_analyzer_run(analyzer, 0.3, false, true);
if (err != 0) {
printf("Failed to run analyzer: %s\n", needle_error_to_str(err));
goto done;
Expand Down
3 changes: 1 addition & 2 deletions needle-capi/needle.h
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ typedef struct FrameHashes FrameHashes;
* return;
* }
*
* err = needle_audio_analyzer_run(analyzer, 0.3, 3.0, true);
* err = needle_audio_analyzer_run(analyzer, 0.3, false, true);
* if (err != 0) {
* printf("Failed to run analyzer: %s\n", needle_error_to_str(err));
* }
Expand Down Expand Up @@ -207,7 +207,6 @@ void needle_audio_analyzer_print_paths(const struct NeedleAudioAnalyzer *analyze
* Run the [NeedleAudioAnalyzer].
*/
enum NeedleError needle_audio_analyzer_run(struct NeedleAudioAnalyzer *analyzer,
float hash_period,
float hash_duration,
bool persist,
bool threading);
Expand Down
14 changes: 6 additions & 8 deletions needle-capi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
//! }
//!
//! // Run the analyzer.
//! err = needle_audio_analyzer_run(analyzer, 0.3, 3.0, true);
//! err = needle_audio_analyzer_run(analyzer, 0.3, false, true);
//! if (err != 0) {
//! printf("Failed to run analyzer: %s\n", needle_error_to_str(err));
//! goto done;
Expand Down Expand Up @@ -336,7 +336,7 @@ impl From<audio::FrameHashes> for FrameHashes {
/// return;
/// }
///
/// err = needle_audio_analyzer_run(analyzer, 0.3, 3.0, true);
/// err = needle_audio_analyzer_run(analyzer, 0.3, false, true);
/// if (err != 0) {
/// printf("Failed to run analyzer: %s\n", needle_error_to_str(err));
/// }
Expand Down Expand Up @@ -464,25 +464,23 @@ pub extern "C" fn needle_audio_analyzer_print_paths(analyzer: *const NeedleAudio
#[no_mangle]
pub extern "C" fn needle_audio_analyzer_run(
analyzer: *mut NeedleAudioAnalyzer,
hash_period: f32,
hash_duration: f32,
persist: bool,
threading: bool,
) -> NeedleError {
if analyzer.is_null() {
return NeedleError::NullArgument;
}
if hash_period <= 0.0 {
return NeedleError::AnalyzerInvalidHashPeriod;
}
if hash_duration < 3.0 {
if hash_duration <= 0.0 {
return NeedleError::AnalyzerInvalidHashDuration;
}

let hash_duration = Duration::from_secs_f32(hash_duration);

// SAFETY: We assume that the user is passing in a _valid_ pointer. Otherwise, all bets are off.
let analyzer = unsafe { analyzer.as_mut().unwrap() };

match analyzer.inner.run(persist, threading) {
match analyzer.inner.run(hash_duration, persist, threading) {
Ok(frame_hashes) => {
// Store the frame hashes for later use.
analyzer.frame_hashes = frame_hashes.into_iter().map(|f| f.into()).collect();
Expand Down
71 changes: 54 additions & 17 deletions needle/src/audio/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ impl<P: AsRef<Path>> Analyzer<P> {
stream_idx: usize,
duration: Option<Duration>,
seek_to: Option<Duration>,
hash_duration: Option<Duration>,
threaded: bool,
) -> Result<(Vec<(u32, Duration)>, Duration)> {
let span = tracing::span!(tracing::Level::TRACE, "process_frames");
Expand Down Expand Up @@ -287,14 +288,23 @@ impl<P: AsRef<Path>> Analyzer<P> {
let chromaprint_hash_delay = chromaprint_ctx.get_delay()?;
let item_duration = chromaprint_ctx.get_item_duration()?;

// We can use the given hash duration and Chromaprint's item duration to
// figure out how many hashes we can skip.
let step_by = if let Some(hash_duration) = hash_duration {
hash_duration.as_millis() as usize / item_duration.as_millis() as usize
} else {
1
};

for (i, hash) in chromaprint_ctx
.get_fingerprint_raw()?
.get()
.iter()
.enumerate()
.step_by(step_by)
{
// Compute a timestamp for the current hash.
// The timestamp for the hash is based on the overall hash delay and the
// The timestamp is based on the overall hash delay and the
// item (hash) duration.
let ts = chromaprint_hash_delay + item_duration.mul_f32(i as f32);
hashes.push((*hash, ts));
Expand All @@ -307,15 +317,23 @@ impl<P: AsRef<Path>> Analyzer<P> {
}
}

Ok((hashes, item_duration))
// Return the provided hash duration or the default item duration.
let hash_duration = hash_duration.unwrap_or(item_duration);

Ok((hashes, hash_duration))
}

pub(crate) fn run_single(&self, path: impl AsRef<Path>, persist: bool) -> Result<FrameHashes> {
pub(crate) fn run_single(
&self,
path: impl AsRef<Path>,
hash_duration: Duration,
persist: bool,
) -> Result<FrameHashes> {
let span = tracing::span!(tracing::Level::TRACE, "run");
let _enter = span.enter();

let path = path.as_ref();
let frame_hash_path = path.with_extension(super::FRAME_HASH_DATA_FILE_EXT);
let frame_hash_path = path.with_extension(crate::FRAME_HASH_DATA_FILE_EXT);

// Check if we've already analyzed this video by comparing MD5 hashes.
let md5 = crate::util::compute_header_md5sum(path)?;
Expand All @@ -341,17 +359,16 @@ impl<P: AsRef<Path>> Analyzer<P> {
//
// As an example, Matroska does not store the duration in the stream; it
// only stores it in the format context.
let duration_raw = if stream.duration() >= 0 {
let duration_raw = if stream.duration() > 0 {
stream.duration()
} else {
if ctx.duration() <= 0 {
// Just in case.
panic!("no duration found in stream or format context")
}
} else if ctx.duration() > 0 {
// NOTE: The format-level duration is in milliseconds in time base units.
// In other words, after multiplying by the time base, the result will be
// in ms.
ctx.duration() / 1000
} else {
// Just in case.
panic!("no duration found in stream or format context")
};

let stream_duration = super::util::to_timestamp(time_base, duration_raw);
Expand All @@ -360,13 +377,27 @@ impl<P: AsRef<Path>> Analyzer<P> {

let opening_duration = stream_duration.mul_f32(self.opening_search_percentage);

let (opening_hashes, hash_duration) =
Self::process_frames(&mut ctx, stream_idx, Some(opening_duration), None, threaded)?;
let (opening_hashes, hash_duration) = Self::process_frames(
&mut ctx,
stream_idx,
Some(opening_duration),
None,
Some(hash_duration),
threaded,
)?;
let mut ending_hashes = Vec::new();
if self.include_endings {
let ending_seek_to = stream_duration.mul_f32(1.0 - self.ending_search_percentage);
ending_hashes.extend(
Self::process_frames(&mut ctx, stream_idx, None, Some(ending_seek_to), threaded)?.0,
Self::process_frames(
&mut ctx,
stream_idx,
None,
Some(ending_seek_to),
Some(hash_duration),
threaded,
)?
.0,
);
}

Expand All @@ -391,7 +422,12 @@ impl<P: AsRef<Path>> Analyzer<P> {

impl<P: AsRef<Path> + Sync> Analyzer<P> {
/// Runs this analyzer.
pub fn run(&self, persist: bool, threading: bool) -> Result<Vec<FrameHashes>> {
pub fn run(
&self,
hash_duration: Duration,
persist: bool,
threading: bool,
) -> Result<Vec<FrameHashes>> {
if self.videos.len() == 0 {
return Err(Error::AnalyzerMissingPaths.into());
}
Expand All @@ -404,14 +440,14 @@ impl<P: AsRef<Path> + Sync> Analyzer<P> {
data = self
.videos
.par_iter()
.map(|path| self.run_single(path, persist).unwrap())
.map(|path| self.run_single(path, hash_duration, persist).unwrap())
.collect::<Vec<_>>();
}
} else {
data.extend(
self.videos
.iter()
.map(|path| self.run_single(path, persist).unwrap()),
.map(|path| self.run_single(path, hash_duration, persist).unwrap()),
);
}

Expand All @@ -438,7 +474,8 @@ mod test {
fn test_analyzer() {
let paths = get_sample_paths();
let analyzer = Analyzer::from_files(paths.clone(), false, false);
let data = analyzer.run(false, false).unwrap();
let hash_duration = Duration::from_secs_f32(crate::audio::DEFAULT_HASH_DURATION);
let data = analyzer.run(hash_duration, false, false).unwrap();
insta::assert_debug_snapshot!(data);
}
}
4 changes: 2 additions & 2 deletions needle/src/audio/comparator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ impl<P: AsRef<Path>> Comparator<P> {
let skip_file = video
.as_ref()
.to_owned()
.with_extension(super::SKIP_FILE_EXT);
.with_extension(crate::SKIP_FILE_EXT);
if !skip_file.exists() {
return Ok(false);
}
Expand Down Expand Up @@ -347,7 +347,7 @@ impl<P: AsRef<Path>> Comparator<P> {
let skip_file = video
.as_ref()
.to_owned()
.with_extension(super::SKIP_FILE_EXT);
.with_extension(crate::SKIP_FILE_EXT);
let mut skip_file = std::fs::File::create(skip_file)?;
let data = SkipFile {
opening,
Expand Down
5 changes: 3 additions & 2 deletions needle/src/audio/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,16 @@ impl FrameHashes {
if !analyze {
let path = video
.to_owned()
.with_extension(super::FRAME_HASH_DATA_FILE_EXT);
.with_extension(crate::FRAME_HASH_DATA_FILE_EXT);
Self::from_path(&path)
} else {
tracing::debug!(
"starting in-place video analysis for {}...",
video.display()
);
let analyzer = super::Analyzer::<&Path>::default().with_force(true);
let frame_hashes = analyzer.run_single(video, false)?;
let hash_duration = Duration::from_secs_f32(super::DEFAULT_HASH_DURATION);
let frame_hashes = analyzer.run_single(video, hash_duration, false)?;
tracing::debug!("completed in-place video analysis for {}", video.display());
Ok(frame_hashes)
}
Expand Down
13 changes: 2 additions & 11 deletions needle/src/audio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,13 @@ pub const DEFAULT_MIN_OPENING_DURATION: u16 = 20; // seconds
/// A match will only be considered as an ending if it runs for at least this long.
pub const DEFAULT_MIN_ENDING_DURATION: u16 = 20; // seconds

/// Default hash period (seconds).
///
/// This is the time (in seconds) between successive frame hashes.
pub const DEFAULT_HASH_PERIOD: f32 = 0.3;

/// Default hash duration (seconds).
///
/// This is the duration of audio used to generate each frame hash. The minimum is 3 seconds -
/// this is a constraint imposed by the underlying audio fingerprinting algorithm, Chromaprint.
pub const DEFAULT_HASH_DURATION: f32 = 3.0;
/// This is the duration of audio used to generate each frame hash.
pub const DEFAULT_HASH_DURATION: f32 = 0.3;

/// Default opening and ending time padding (seconds).
///
/// This amount is added to the start time and subtracted from the end time of each opening and ending.
/// The idea is to provide a buffer that reduces the amount of missed content.
pub const DEFAULT_OPENING_AND_ENDING_TIME_PADDING: f32 = 0.0; // seconds

static FRAME_HASH_DATA_FILE_EXT: &str = "needle.dat";
static SKIP_FILE_EXT: &str = "needle.skip.json";
3 changes: 3 additions & 0 deletions needle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,6 @@ pub enum Error {

/// Common result type.
pub type Result<T> = std::result::Result<T, Error>;

pub(crate) static FRAME_HASH_DATA_FILE_EXT: &str = "needle.dat";
pub(crate) static SKIP_FILE_EXT: &str = "needle.skip.json";
21 changes: 19 additions & 2 deletions needle/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ enum Commands {
)]
ending_search_percentage: f32,

#[clap(
long,
default_value_t = audio::DEFAULT_HASH_DURATION,
value_parser = clap::value_parser!(f32),
help = "Amount of time (in seconds) that each hash represents. The default should be sufficient for the vast majority of cases."
)]
hash_duration: f32,

#[clap(
long,
default_value = "false",
Expand Down Expand Up @@ -196,6 +204,7 @@ impl Cli {
Commands::Analyze {
opening_search_percentage,
ending_search_percentage,
hash_duration,
..
} => {
if opening_search_percentage >= 1.0 {
Expand All @@ -212,6 +221,13 @@ impl Cli {
)
.exit();
}
if hash_duration <= 0.0 {
cmd.error(
ErrorKind::InvalidValue,
"hash_duration must be greater than 0",
)
.exit();
}
}
Commands::Search {
hash_match_threshold,
Expand Down Expand Up @@ -263,6 +279,7 @@ fn main() -> needle::Result<()> {
ref mode,
opening_search_percentage,
ending_search_percentage,
hash_duration,
include_endings,
threaded_decoding,
force,
Expand All @@ -275,8 +292,8 @@ fn main() -> needle::Result<()> {
.with_opening_search_percentage(opening_search_percentage)
.with_ending_search_percentage(ending_search_percentage)
.with_include_endings(include_endings);

analyzer.run(true, !args.no_threading)?;
let hash_duration = Duration::from_secs_f32(hash_duration);
analyzer.run(hash_duration, true, !args.no_threading)?;
}
#[cfg(feature = "video")]
Mode::Video => {
Expand Down
14 changes: 12 additions & 2 deletions needle/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,24 @@ pub fn format_time(t: Duration) -> String {
/// If `audio` is set to true, this function will ensure that the video contains *at least* one audio stream.
/// This flag is only used when `full` is set to **true**.
pub fn is_valid_video_file(path: impl AsRef<Path>, full: bool, audio: bool) -> bool {
let path = path.as_ref();

if path
.to_str()
.unwrap()
.ends_with(crate::FRAME_HASH_DATA_FILE_EXT)
{
return false;
}

if !full {
let mut buf = [0u8; 8192];
let mut f = std::fs::File::open(path.as_ref()).unwrap();
let mut f = std::fs::File::open(path).unwrap();
f.read(&mut buf).unwrap();
return infer::is_video(&buf);
}

if let Ok(input) = ffmpeg_next::format::input(&path.as_ref()) {
if let Ok(input) = ffmpeg_next::format::input(&path) {
let num_video_streams = input
.streams()
.filter(|s| s.parameters().medium() == ffmpeg_next::util::media::Type::Video)
Expand Down

0 comments on commit 61065e1

Please sign in to comment.