Skip to content
Open
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +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) |
Expand Down
53 changes: 45 additions & 8 deletions audio_offset_finder/audio_offset_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,21 @@ 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,
trim1=None,
trim2=None,
start1=None,
start2=None,
):
"""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)
Expand All @@ -55,6 +69,15 @@ 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.
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
Expand All @@ -65,9 +88,11 @@ def find_offset_between_files(file1, file2, fs=8000, trim=None, hop_length=128,
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
Expand All @@ -78,13 +103,22 @@ 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)
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)
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


Expand Down Expand Up @@ -209,7 +243,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
Expand All @@ -221,6 +255,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
-------
Expand All @@ -235,7 +272,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"]
Expand Down
53 changes: 50 additions & 3 deletions audio_offset_finder/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,41 @@ 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",
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(
"--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"
)
Expand All @@ -55,8 +90,18 @@ 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, hop_length=int(args.resolution)
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:
print(e, file=sys.stderr)
Expand Down Expand Up @@ -96,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")
Expand Down
42 changes: 42 additions & 0 deletions tests/audio_offset_finder_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,48 @@ 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_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"),
hop_length=128,
trim1=60,
start1=320,
)
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.
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(334.608)
assert results["time_offset_shift"] == pytest.approx(300)
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]])
Expand Down
30 changes: 30 additions & 0 deletions tests/tool_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,33 @@ 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


def test_per_file_trim_and_start():
import json

# --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"
)
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"]) == 334.608
Loading