From b0711951e55a90f2317af9a7fc70c45df1310a11 Mon Sep 17 00:00:00 2001 From: Alexander Rodionov Date: Thu, 28 May 2026 09:53:15 +0000 Subject: [PATCH 1/3] Add --start to skip the first n seconds of each audio file Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + audio_offset_finder/audio_offset_finder.py | 18 +++++++++++++----- audio_offset_finder/cli.py | 14 +++++++++++++- tests/audio_offset_finder_test.py | 12 ++++++++++++ tests/tool_test.py | 14 ++++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d02f5b5..df2afc9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ The following command-line options can be provided to alter the behaviour of the | --within audio file | ...within this file | | --sr sample rate | Target sample rate in Hz during downsampling (default: 8000) | | --trim seconds | Only use the first n seconds of each audio file | +| --start seconds | Skip the first n seconds of each audio file before processing. Combined with `--trim`, the audio considered is the window `[start, start + trim]`. Default: 0. | | --resolution samples | Resolution (maximum accuracy) of search in samples (default: 128) | | --show-plot | Display a plot of the cross-correlation results | | --save-plot filename | Save a plot of the cross-correlation results to a file (in a format that matches the extension you provide - png, ps, pdf, svg) | diff --git a/audio_offset_finder/audio_offset_finder.py b/audio_offset_finder/audio_offset_finder.py index 694ecb2..e2c38b5 100644 --- a/audio_offset_finder/audio_offset_finder.py +++ b/audio_offset_finder/audio_offset_finder.py @@ -38,7 +38,9 @@ def mfcc(audio, win_length=256, nfft=512, fs=16000, hop_length=128, numcep=13): ] -def find_offset_between_files(file1, file2, fs=8000, trim=None, hop_length=128, win_length=256, nfft=512, max_frames=2000): +def find_offset_between_files( + file1, file2, fs=8000, trim=None, start=0, hop_length=128, win_length=256, nfft=512, max_frames=2000 +): """Find the offset time offset between two audio files. This function takes in two file paths, and (assuming they are media files with a valid audio track) @@ -55,6 +57,9 @@ def find_offset_between_files(file1, file2, fs=8000, trim=None, hop_length=128, The sampling rate that the audio should be resampled to prior to MFCC calculation, in Hz trim: int The length to which input files will be truncated before processing, in seconds. A value of "None" indicates no trimming. + start: float + The number of seconds to skip at the beginning of each input file before processing. Defaults to 0. + When combined with "trim", the audio considered is the window [start, start + trim]. hop_length: int The number of samples (at the resampled rate "fs") to skip between each calculated MFCC frame win_length: int @@ -78,8 +83,8 @@ def find_offset_between_files(file1, file2, fs=8000, trim=None, hop_length=128, ------ InsufficientAudioException if the audio supplied is too short to analyse. """ - tmp1 = convert_and_trim(file1, fs, trim) - tmp2 = convert_and_trim(file2, fs, trim) + tmp1 = convert_and_trim(file1, fs, trim, start=start) + tmp2 = convert_and_trim(file2, fs, trim, start=start) a1 = wavfile.read(tmp1, mmap=True)[1].astype(float) a2 = wavfile.read(tmp2, mmap=True)[1].astype(float) offset_dict = find_offset_between_buffers(a1, a2, fs, hop_length, win_length, nfft) @@ -209,7 +214,7 @@ def std_mfcc(array): return (array - np.mean(array, axis=0)) / np.std(array, axis=0) -def convert_and_trim(afile, fs, trim=None): +def convert_and_trim(afile, fs, trim=None, start=0): """Converts the input media to a temporary 16-bit WAV file and trims it to length. Parameters @@ -221,6 +226,9 @@ def convert_and_trim(afile, fs, trim=None): trim: float The length to which the output audio should be trimmed, in seconds. (Audio beyond this point will be discarded.) A value of "None" implies no trimming. + start: float + The number of seconds to skip at the beginning of the audio. Audio before this point will be discarded. + Defaults to 0. Returns ------- @@ -235,7 +243,7 @@ def convert_and_trim(afile, fs, trim=None): ffmpeg_command += ["-i", afile] ffmpeg_command += ["-ac", "1"] ffmpeg_command += ["-ar", str(fs)] - ffmpeg_command += ["-ss", "0"] + ffmpeg_command += ["-ss", str(start)] if trim: ffmpeg_command += ["-t", str(trim)] ffmpeg_command += ["-acodec", "pcm_s16le"] diff --git a/audio_offset_finder/cli.py b/audio_offset_finder/cli.py index 4be7396..6fc4ba9 100644 --- a/audio_offset_finder/cli.py +++ b/audio_offset_finder/cli.py @@ -34,6 +34,13 @@ def main(argv): parser.add_argument("--within", metavar="audio file", type=str, help="...within this file.") parser.add_argument("--sr", metavar="sample rate", type=int, default=8000, help="Resample to this rate before searching") parser.add_argument("--trim", metavar="seconds", type=int, help="Only consider the first n seconds of the audio files") + parser.add_argument( + "--start", + metavar="seconds", + type=float, + default=0, + help="Skip the first n seconds of each audio file before processing. Combined with --trim, considers the window [start, start+trim].", + ) parser.add_argument( "--resolution", metavar="samples", type=int, default=128, help="Resolution (maximum accuracy) of search in samples" ) @@ -56,7 +63,12 @@ def main(argv): trim = int(args.trim) results = find_offset_between_files( - args.within, args.find_offset_of, fs=int(args.sr), trim=trim, hop_length=int(args.resolution) + args.within, + args.find_offset_of, + fs=int(args.sr), + trim=trim, + start=args.start, + hop_length=int(args.resolution), ) except Exception as e: print(e, file=sys.stderr) diff --git a/tests/audio_offset_finder_test.py b/tests/audio_offset_finder_test.py index 15f50c1..c57bc1b 100644 --- a/tests/audio_offset_finder_test.py +++ b/tests/audio_offset_finder_test.py @@ -86,6 +86,18 @@ def test_std_mfcc(): np.testing.assert_array_equal(std_mfcc(m), np.array([[-1.0 / s1, -1.0 / s2, -0.5 / s3], [1.0 / s1, 1.0 / s2, 0.5 / s3]])) +def test_find_offset_with_start(): + # Skipping the first N seconds identically in both files preserves the relative offset. + results = find_offset_between_files(path("timbl_1.mp3"), path("timbl_2.mp3"), hop_length=160, trim=25, start=5) + assert results["time_offset"] == pytest.approx(12.26) + assert results["standard_score"] > 10 + + # Auto-correlation with a non-zero start still finds offset 0. + results = find_offset_between_files(path("timbl_1.mp3"), path("timbl_1.mp3"), hop_length=160, trim=20, start=10) + assert results["time_offset"] == pytest.approx(0.0) + assert results["standard_score"] > 10 + + def test_cross_correlation(): m1 = np.array([[-0.5, -0.4, -0.4], [0.5, 0.5, 0.4], [0.1, -0.1, 0.1]]) m2 = np.array([[0.5, 0.5, 0.4], [0.1, -0.1, 0.1], [-0.6, 0.0, -0.3]]) diff --git a/tests/tool_test.py b/tests/tool_test.py index e979486..e0d4ef8 100644 --- a/tests/tool_test.py +++ b/tests/tool_test.py @@ -71,3 +71,17 @@ def test_json(): assert len(json_array) == 2 assert pytest.approx(json_array["time_offset"]) == 12.26 assert pytest.approx(json_array["standard_score"], rel=1e-2) == 28.99 + + +def test_start(): + import json + + args = ( + "--find-offset-of tests/audio/timbl_2.mp3 --within tests/audio/timbl_1.mp3 --resolution 160 " + "--trim 25 --start 5 --json" + ) + with patch("sys.stdout", new=StringIO()) as fakeStdout: + main(args.split()) + output = fakeStdout.getvalue().strip() + result = json.loads(output) + assert pytest.approx(result["time_offset"]) == 12.26 From 2d1eb2e75f104430734ee71aed852de3f2c74444 Mon Sep 17 00:00:00 2001 From: Alexander Rodionov Date: Thu, 28 May 2026 10:08:01 +0000 Subject: [PATCH 2/3] Add --trim-of/--trim-within/--start-of/--start-within for per-file trim Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 +++ audio_offset_finder/audio_offset_finder.py | 28 ++++++++++++++++-- audio_offset_finder/cli.py | 33 ++++++++++++++++++++++ tests/audio_offset_finder_test.py | 28 ++++++++++++++++++ tests/tool_test.py | 16 +++++++++++ 5 files changed, 106 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index df2afc9..8d6a8dd 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,11 @@ The following command-line options can be provided to alter the behaviour of the | --within audio file | ...within this file | | --sr sample rate | Target sample rate in Hz during downsampling (default: 8000) | | --trim seconds | Only use the first n seconds of each audio file | +| --trim-of seconds | Override `--trim` for the `--find-offset-of` file only | +| --trim-within seconds | Override `--trim` for the `--within` file only | | --start seconds | Skip the first n seconds of each audio file before processing. Combined with `--trim`, the audio considered is the window `[start, start + trim]`. Default: 0. | +| --start-of seconds | Override `--start` for the `--find-offset-of` file only | +| --start-within seconds | Override `--start` for the `--within` file only | | --resolution samples | Resolution (maximum accuracy) of search in samples (default: 128) | | --show-plot | Display a plot of the cross-correlation results | | --save-plot filename | Save a plot of the cross-correlation results to a file (in a format that matches the extension you provide - png, ps, pdf, svg) | diff --git a/audio_offset_finder/audio_offset_finder.py b/audio_offset_finder/audio_offset_finder.py index e2c38b5..e42ad0d 100644 --- a/audio_offset_finder/audio_offset_finder.py +++ b/audio_offset_finder/audio_offset_finder.py @@ -39,7 +39,19 @@ def mfcc(audio, win_length=256, nfft=512, fs=16000, hop_length=128, numcep=13): def find_offset_between_files( - file1, file2, fs=8000, trim=None, start=0, hop_length=128, win_length=256, nfft=512, max_frames=2000 + file1, + file2, + fs=8000, + trim=None, + start=0, + hop_length=128, + win_length=256, + nfft=512, + max_frames=2000, + trim1=None, + trim2=None, + start1=None, + start2=None, ): """Find the offset time offset between two audio files. @@ -57,9 +69,15 @@ def find_offset_between_files( The sampling rate that the audio should be resampled to prior to MFCC calculation, in Hz trim: int The length to which input files will be truncated before processing, in seconds. A value of "None" indicates no trimming. + Used for both files unless overridden by trim1/trim2. start: float The number of seconds to skip at the beginning of each input file before processing. Defaults to 0. When combined with "trim", the audio considered is the window [start, start + trim]. + Used for both files unless overridden by start1/start2. + trim1, trim2: int + Per-file overrides for "trim", applied to file1 and file2 respectively. When set, take precedence over "trim". + start1, start2: float + Per-file overrides for "start", applied to file1 and file2 respectively. When set, take precedence over "start". hop_length: int The number of samples (at the resampled rate "fs") to skip between each calculated MFCC frame win_length: int @@ -83,8 +101,12 @@ def find_offset_between_files( ------ InsufficientAudioException if the audio supplied is too short to analyse. """ - tmp1 = convert_and_trim(file1, fs, trim, start=start) - tmp2 = convert_and_trim(file2, fs, trim, start=start) + trim1 = trim if trim1 is None else trim1 + trim2 = trim if trim2 is None else trim2 + start1 = start if start1 is None else start1 + start2 = start if start2 is None else start2 + tmp1 = convert_and_trim(file1, fs, trim1, start=start1) + tmp2 = convert_and_trim(file2, fs, trim2, start=start2) a1 = wavfile.read(tmp1, mmap=True)[1].astype(float) a2 = wavfile.read(tmp2, mmap=True)[1].astype(float) offset_dict = find_offset_between_buffers(a1, a2, fs, hop_length, win_length, nfft) diff --git a/audio_offset_finder/cli.py b/audio_offset_finder/cli.py index 6fc4ba9..04a50ff 100644 --- a/audio_offset_finder/cli.py +++ b/audio_offset_finder/cli.py @@ -34,6 +34,20 @@ def main(argv): parser.add_argument("--within", metavar="audio file", type=str, help="...within this file.") parser.add_argument("--sr", metavar="sample rate", type=int, default=8000, help="Resample to this rate before searching") parser.add_argument("--trim", metavar="seconds", type=int, help="Only consider the first n seconds of the audio files") + parser.add_argument( + "--trim-of", + metavar="seconds", + type=int, + dest="trim_of", + help="Override --trim for the --find-offset-of file only.", + ) + parser.add_argument( + "--trim-within", + metavar="seconds", + type=int, + dest="trim_within", + help="Override --trim for the --within file only.", + ) parser.add_argument( "--start", metavar="seconds", @@ -41,6 +55,20 @@ def main(argv): default=0, help="Skip the first n seconds of each audio file before processing. Combined with --trim, considers the window [start, start+trim].", ) + parser.add_argument( + "--start-of", + metavar="seconds", + type=float, + dest="start_of", + help="Override --start for the --find-offset-of file only.", + ) + parser.add_argument( + "--start-within", + metavar="seconds", + type=float, + dest="start_within", + help="Override --start for the --within file only.", + ) parser.add_argument( "--resolution", metavar="samples", type=int, default=128, help="Resolution (maximum accuracy) of search in samples" ) @@ -62,12 +90,17 @@ def main(argv): if args.trim: trim = int(args.trim) + # file1 = --within, file2 = --find-offset-of results = find_offset_between_files( args.within, args.find_offset_of, fs=int(args.sr), trim=trim, start=args.start, + trim1=args.trim_within, + trim2=args.trim_of, + start1=args.start_within, + start2=args.start_of, hop_length=int(args.resolution), ) except Exception as e: diff --git a/tests/audio_offset_finder_test.py b/tests/audio_offset_finder_test.py index c57bc1b..5186072 100644 --- a/tests/audio_offset_finder_test.py +++ b/tests/audio_offset_finder_test.py @@ -98,6 +98,34 @@ def test_find_offset_with_start(): assert results["standard_score"] > 10 +def test_find_offset_with_per_file_trim_and_start(): + # r4_excerpt.ogg (4.38s) matches r4.ogg at offset 334.608s. + # Trim r4 (file1) tightly around the match while leaving the excerpt (file2) alone. + results = find_offset_between_files( + path("r4.ogg"), + path("r4_excerpt.ogg"), + hop_length=128, + trim1=60, + start1=320, + ) + # After skipping 320s of r4, the excerpt now sits at 334.608 - 320 = 14.608s + assert results["time_offset"] == pytest.approx(14.608) + assert results["standard_score"] > 10 + + # Per-file overrides take precedence over the shared --trim/--start values. + results = find_offset_between_files( + path("r4.ogg"), + path("r4_excerpt.ogg"), + hop_length=128, + trim=20 * 60, # would apply to both, but trim2 leaves the excerpt full-length anyway + start=0, + start1=300, + trim1=60, + ) + assert results["time_offset"] == pytest.approx(34.608) + assert results["standard_score"] > 10 + + def test_cross_correlation(): m1 = np.array([[-0.5, -0.4, -0.4], [0.5, 0.5, 0.4], [0.1, -0.1, 0.1]]) m2 = np.array([[0.5, 0.5, 0.4], [0.1, -0.1, 0.1], [-0.6, 0.0, -0.3]]) diff --git a/tests/tool_test.py b/tests/tool_test.py index e0d4ef8..6261173 100644 --- a/tests/tool_test.py +++ b/tests/tool_test.py @@ -85,3 +85,19 @@ def test_start(): output = fakeStdout.getvalue().strip() result = json.loads(output) assert pytest.approx(result["time_offset"]) == 12.26 + + +def test_per_file_trim_and_start(): + import json + + # --start-within shifts only the --within file; the reported offset shifts accordingly. + args = ( + "--find-offset-of tests/audio/r4_excerpt.ogg --within tests/audio/r4.ogg " + "--resolution 128 --start-within 320 --trim-within 60 --json" + ) + with patch("sys.stdout", new=StringIO()) as fakeStdout: + main(args.split()) + output = fakeStdout.getvalue().strip() + result = json.loads(output) + # 334.608 - 320 = 14.608s + assert pytest.approx(result["time_offset"]) == 14.608 From f2d9d547aab3ce811bad9c729b729712fbb5b051 Mon Sep 17 00:00:00 2001 From: Alexander Rodionov Date: Thu, 28 May 2026 10:34:36 +0000 Subject: [PATCH 3/3] Report offset in original-file coordinates after asymmetric trimming Co-Authored-By: Claude Opus 4.7 (1M context) --- audio_offset_finder/audio_offset_finder.py | 13 ++++++++++--- audio_offset_finder/cli.py | 6 ++++-- tests/audio_offset_finder_test.py | 8 +++++--- tests/tool_test.py | 6 +++--- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/audio_offset_finder/audio_offset_finder.py b/audio_offset_finder/audio_offset_finder.py index e42ad0d..396c5b3 100644 --- a/audio_offset_finder/audio_offset_finder.py +++ b/audio_offset_finder/audio_offset_finder.py @@ -88,9 +88,11 @@ def find_offset_between_files( Returns ------- A dict containing the following: - time_offset (float): the most likely offset of file2 compared to file1, in seconds. A positive value indicates that - file2 starts after file1 - frame_offset (int): the offset of file2 compared to file1, measured in MFCC frames + time_offset (float): the most likely offset of file2 compared to file1, in seconds, expressed in the + coordinates of the *original* (un-trimmed) input files. A positive value indicates that + file2 starts after file1. + frame_offset (int): the offset of file2 compared to file1, measured in MFCC frames over the trimmed buffers + time_offset_shift (float): start1 - start2, the amount added to the trim-relative offset to produce time_offset standard_score (float): the standard score of the highest correlation coefficient in the cross-correlation curve correlation (numpy int array): the 1D array of correlation coefficients calculated for the two input files time_scale: the scalar factor that is multiplied to frame offsets to convert them to time offsets @@ -112,6 +114,11 @@ def find_offset_between_files( offset_dict = find_offset_between_buffers(a1, a2, fs, hop_length, win_length, nfft) os.remove(tmp1) os.remove(tmp2) + # Convert the trim-relative time_offset back into original-file coordinates so that the + # reported value tells the user where file2 sits within the *original* file1. + time_offset_shift = start1 - start2 + offset_dict["time_offset"] += time_offset_shift + offset_dict["time_offset_shift"] = time_offset_shift return offset_dict diff --git a/audio_offset_finder/cli.py b/audio_offset_finder/cli.py index 04a50ff..620f6b1 100644 --- a/audio_offset_finder/cli.py +++ b/audio_offset_finder/cli.py @@ -141,9 +141,11 @@ def plot_results(args, results): pyplot.plot(xaxis_range, plot_data) ax = pyplot.gca() - # Scale x values from frame numbers to time: t = mx + c, but c=0 for a symetrical cross-correlation + # Scale x values from frame numbers to time: t = mx + c, where c is the shift introduced by + # asymmetric per-file trimming (start1 - start2; zero when both files share the same start). m = results["time_scale"] - ticks_x = ticker.FuncFormatter(lambda x, pos: "{0:g}".format(x * m)) + c = results.get("time_offset_shift", 0) + ticks_x = ticker.FuncFormatter(lambda x, pos: "{0:g}".format(x * m + c)) ax.xaxis.set_major_locator(ticker.MaxNLocator(nbins="auto")) ax.xaxis.set_major_formatter(ticks_x) ax.set_xlabel("Time offset /s", fontsize="12") diff --git a/tests/audio_offset_finder_test.py b/tests/audio_offset_finder_test.py index 5186072..21dbcaa 100644 --- a/tests/audio_offset_finder_test.py +++ b/tests/audio_offset_finder_test.py @@ -101,6 +101,7 @@ def test_find_offset_with_start(): def test_find_offset_with_per_file_trim_and_start(): # r4_excerpt.ogg (4.38s) matches r4.ogg at offset 334.608s. # Trim r4 (file1) tightly around the match while leaving the excerpt (file2) alone. + # The reported time_offset is in *original* file coordinates, so it stays at 334.608. results = find_offset_between_files( path("r4.ogg"), path("r4_excerpt.ogg"), @@ -108,8 +109,8 @@ def test_find_offset_with_per_file_trim_and_start(): trim1=60, start1=320, ) - # After skipping 320s of r4, the excerpt now sits at 334.608 - 320 = 14.608s - assert results["time_offset"] == pytest.approx(14.608) + assert results["time_offset"] == pytest.approx(334.608) + assert results["time_offset_shift"] == pytest.approx(320) assert results["standard_score"] > 10 # Per-file overrides take precedence over the shared --trim/--start values. @@ -122,7 +123,8 @@ def test_find_offset_with_per_file_trim_and_start(): start1=300, trim1=60, ) - assert results["time_offset"] == pytest.approx(34.608) + assert results["time_offset"] == pytest.approx(334.608) + assert results["time_offset_shift"] == pytest.approx(300) assert results["standard_score"] > 10 diff --git a/tests/tool_test.py b/tests/tool_test.py index 6261173..18a186e 100644 --- a/tests/tool_test.py +++ b/tests/tool_test.py @@ -90,7 +90,8 @@ def test_start(): def test_per_file_trim_and_start(): import json - # --start-within shifts only the --within file; the reported offset shifts accordingly. + # --start-within narrows the search to a window of the --within file, but the reported + # offset is in *original* --within file coordinates. args = ( "--find-offset-of tests/audio/r4_excerpt.ogg --within tests/audio/r4.ogg " "--resolution 128 --start-within 320 --trim-within 60 --json" @@ -99,5 +100,4 @@ def test_per_file_trim_and_start(): main(args.split()) output = fakeStdout.getvalue().strip() result = json.loads(output) - # 334.608 - 320 = 14.608s - assert pytest.approx(result["time_offset"]) == 14.608 + assert pytest.approx(result["time_offset"]) == 334.608