Skip to content
Merged
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
13 changes: 11 additions & 2 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,21 @@
require 'rake/testtask'
require_relative 'lib/wavesync/version'

Rake::TestTask.new do |t|
Rake::TestTask.new(:test) do |t|
t.libs << 'test'
t.pattern = 'test/**/*_test.rb'
t.pattern = 'test/wavesync/**/*_test.rb'
t.verbose = false
end

namespace :test do
desc 'Run integration tests against connected devices'
Rake::TestTask.new(:integration) do |t|
t.libs << 'test'
t.pattern = 'test/integration/**/*_test.rb'
t.verbose = false
end
end

desc 'Run rubocop, steep check, and tests'
task default: %i[rubocop steep test]

Expand Down
58 changes: 58 additions & 0 deletions rakelib/fixtures.rake
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,63 @@ namespace :fixtures do
"#{FIXTURES_PATH}/44100_#{bit_depth}.#{ext}"
end
end

Rake::Task['fixtures:generate_click_track'].invoke
end

desc 'Generate click track fixture: 120 BPM, 2.5 bars, 44100Hz 16-bit WAV with ACID BPM'
task :generate_click_track do
require_relative '../lib/wavesync/acid_chunk'

click_bpm = 120
click_bars = 2.5
click_sample_rate = 44_100
click_ms = 30
downbeat_freq = 880
beat_freq = 440
output_path = "#{FIXTURES_PATH}/click_120bpm_2_5bars.wav"

beat_duration_ms = (60_000.0 / click_bpm).round
total_beats = (click_bars * 4).to_i
total_duration_s = click_bars * 4 * 60.0 / click_bpm

beat_times_ms = Array.new(total_beats) { |i| i * beat_duration_ms }
downbeat_times_ms = beat_times_ms.select.with_index { |_, i| (i % 4).zero? }
other_beat_times_ms = beat_times_ms - downbeat_times_ms

num_downbeats = downbeat_times_ms.size
num_other_beats = other_beat_times_ms.size

filter_parts = []
filter_parts << "[0]asplit=#{num_downbeats}#{(0...num_downbeats).map { |i| "[d#{i}]" }.join}"
filter_parts << "[1]asplit=#{num_other_beats}#{(0...num_other_beats).map { |i| "[b#{i}]" }.join}"

output_labels = []

downbeat_times_ms.each_with_index do |time_ms, i|
label = "od#{i}"
filter_parts << "[d#{i}]adelay=#{time_ms},apad=whole_dur=#{total_duration_s}[#{label}]"
output_labels << "[#{label}]"
end

other_beat_times_ms.each_with_index do |time_ms, i|
label = "ob#{i}"
filter_parts << "[b#{i}]adelay=#{time_ms},apad=whole_dur=#{total_duration_s}[#{label}]"
output_labels << "[#{label}]"
end

filter_parts << "#{output_labels.join}amix=inputs=#{total_beats}:normalize=0,volume=0.5"

sh 'ffmpeg', '-y',
'-f', 'lavfi', '-i', "sine=frequency=#{downbeat_freq}:sample_rate=#{click_sample_rate}:duration=#{click_ms / 1000.0}",
'-f', 'lavfi', '-i', "sine=frequency=#{beat_freq}:sample_rate=#{click_sample_rate}:duration=#{click_ms / 1000.0}",
'-filter_complex', filter_parts.join(';'),
'-acodec', 'pcm_s16le',
'-ar', click_sample_rate.to_s,
'-t', total_duration_s.to_s,
output_path

Wavesync::AcidChunk.write_bpm_in_place(output_path, click_bpm)
puts "Generated #{output_path} (#{click_bpm} BPM, #{click_bars} bars, #{total_duration_s}s)"
end
end
Binary file added test/fixtures/click_120bpm_2_5bars.wav
Binary file not shown.
112 changes: 112 additions & 0 deletions test/integration/integration_test_case.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# frozen_string_literal: true

require 'securerandom'
require_relative '../wavesync/test_case'
require_relative '../../lib/wavesync'

module Wavesync
class IntegrationTestCase < Wavesync::TestCase
def self.device_model
raise NotImplementedError, "#{self} must define .device_model"
end

def setup
silence_output

config = begin
Wavesync::Config.load
rescue Wavesync::ConfigError
skip 'No wavesync config found (~/.wavesync.yml)'
end

device_config = config.device_configs.find { |dc| dc[:model] == self.class.device_model }
skip "No #{self.class.device_model} device configured in ~/wavesync.yml" unless device_config
skip "Device path not accessible: #{device_config[:path]}" unless File.exist?(device_config[:path])

@device = Wavesync::Device.find_by(name: device_config[:model])
@device_test_path = File.join(device_config[:path], 'wavesync_test', SecureRandom.hex(8))
@source_dir = Dir.mktmpdir

FileUtils.mkdir_p(@device_test_path)
end

def teardown
restore_output
FileUtils.rm_rf(@source_dir) if @source_dir
delete_from_device(@device_test_path) if @device_test_path
end

private

def source_file(dest_name, fixture:, bpm: nil, cue_points: nil)
dest_path = File.join(@source_dir, dest_name)
FileUtils.cp(File.join(FIXTURES_PATH, fixture), dest_path)
audio = Wavesync::Audio.new(dest_path)
audio.write_bpm(bpm) if bpm
audio.write_cue_points(cue_points) if cue_points
dest_path
end

def sync(pad: false)
Wavesync::Scanner.new(@source_dir).sync(@device_test_path, @device, pad: pad)
end

def device_file(relative_path)
File.join(@device_test_path, relative_path)
end

def assert_file_on_device(relative_path)
assert File.exist?(device_file(relative_path)), "Expected file on device: #{relative_path}"
end

def assert_file_not_on_device(relative_path)
refute File.exist?(device_file(relative_path)), "Expected no file on device: #{relative_path}"
end

def assert_acid_bpm(relative_path, expected_bpm)
actual_bpm = Wavesync::AcidChunk.read_bpm(device_file(relative_path))
assert_in_delta expected_bpm, actual_bpm.to_f, 0.01, "Expected ACID BPM #{expected_bpm} in #{relative_path}, got #{actual_bpm}"
end

def assert_no_acid_bpm(relative_path)
actual_bpm = Wavesync::AcidChunk.read_bpm(device_file(relative_path))
assert_nil actual_bpm, "Expected no ACID BPM in #{relative_path}, got #{actual_bpm}"
end

def assert_cue_points_on_device(relative_path, expected_cue_points)
actual_cue_points = Wavesync::CueChunk.read(device_file(relative_path))
assert_equal expected_cue_points.size, actual_cue_points.size,
"Expected #{expected_cue_points.size} cue point(s) in #{relative_path}, got #{actual_cue_points.size}"
expected_cue_points.zip(actual_cue_points).each do |expected, actual|
assert_equal expected[:sample_offset], actual[:sample_offset]
assert_equal expected[:label], actual[:label] if expected.key?(:label)
end
end

def assert_sample_rate_on_device(relative_path, expected_hz)
actual_hz = Wavesync::Audio.new(device_file(relative_path)).sample_rate
assert_equal expected_hz, actual_hz, "Expected sample rate #{expected_hz}Hz in #{relative_path}, got #{actual_hz}"
end

def assert_duration_on_device(relative_path, expected_seconds, tolerance: 0.1)
actual_seconds = Wavesync::Audio.new(device_file(relative_path)).duration
assert_in_delta expected_seconds, actual_seconds, tolerance,
"Expected duration #{expected_seconds}s in #{relative_path}, got #{actual_seconds}s"
end

def delete_from_device(dir_path)
return unless File.exist?(dir_path)

Dir.glob(File.join(dir_path, '**', '*'))
.reverse
.each do |entry|
File.file?(entry) ? File.delete(entry) : Dir.rmdir(entry)
rescue SystemCallError
nil
end
Dir.rmdir(dir_path)
rescue SystemCallError
nil
end
end
end
125 changes: 125 additions & 0 deletions test/integration/octatrack_sync_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# frozen_string_literal: true

require_relative 'integration_test_case'

module Wavesync
class OctatrackSyncTest < IntegrationTestCase
def self.device_model = 'Octatrack'

test 'copies wav without bpm using original filename' do
source_file('track.wav', fixture: '44100_16.wav')
sync
assert_file_on_device 'track.wav'
end

test 'copies wav with bpm, bpm appended to filename' do
source_file('track.wav', fixture: '44100_16.wav', bpm: 120)
sync
assert_file_on_device 'track 120 bpm.wav'
assert_file_not_on_device 'track.wav'
end

test 'copies aif without bpm using original filename' do
source_file('track.aif', fixture: '44100_16.aif')
sync
assert_file_on_device 'track.aif'
end

test 'copies aif with bpm, bpm appended to filename' do
source_file('track.aif', fixture: '44100_16.aif', bpm: 130)
sync
assert_file_on_device 'track 130 bpm.aif'
assert_file_not_on_device 'track.aif'
end

test 'converts wav at 48000hz to 44100hz' do
source_file('track.wav', fixture: '48000_16.wav')
sync
assert_file_on_device 'track.wav'
assert_sample_rate_on_device 'track.wav', 44_100
end

test 'converts wav at 48000hz to 44100hz with bpm appended to filename' do
source_file('track.wav', fixture: '48000_16.wav', bpm: 128)
sync
assert_file_on_device 'track 128 bpm.wav'
assert_sample_rate_on_device 'track 128 bpm.wav', 44_100
end

test 'converts wav at 48000hz to 44100hz, rescaling cue point sample offsets' do
cue_points = [{ identifier: 1, sample_offset: 48_000, label: 'Drop' }]
source_file('track.wav', fixture: '48000_16.wav', cue_points: cue_points)
sync
rescaled_offset = (48_000 * 44_100 / 48_000.0).round
assert_cue_points_on_device 'track.wav', [{ identifier: 1, sample_offset: rescaled_offset, label: 'Drop' }]
end

test 'converts mp3 to wav' do
source_file('track.mp3', fixture: '44100.mp3')
sync
assert_file_on_device 'track.wav'
assert_file_not_on_device 'track.mp3'
end

test 'converts mp3 to wav with bpm appended to filename' do
source_file('track.mp3', fixture: '44100.mp3', bpm: 130)
sync
assert_file_on_device 'track 130 bpm.wav'
end

test 'converts m4a to wav' do
source_file('track.m4a', fixture: '44100.m4a')
sync
assert_file_on_device 'track.wav'
assert_file_not_on_device 'track.m4a'
end

test 'converts m4a to wav with bpm appended to filename' do
source_file('track.m4a', fixture: '44100.m4a', bpm: 95)
sync
assert_file_on_device 'track 95 bpm.wav'
end

test 'pads track to 64-bar boundary when bpm is present' do
source_file('click.wav', fixture: 'click_120bpm_2_5bars.wav')
sync(pad: true)
seconds_per_bar = 4 * 60.0 / 120
expected_duration = 64 * seconds_per_bar
assert_file_on_device 'click 120 bpm.wav'
assert_duration_on_device 'click 120 bpm.wav', expected_duration, tolerance: 0.5
end

test 'does not pad track when bpm is absent' do
source_file('track.wav', fixture: '44100_16.wav')
sync(pad: true)
assert_file_on_device 'track.wav'
assert_duration_on_device 'track.wav', 1.0, tolerance: 0.2
end

test 'replaces stale bpm filename on device when source bpm changes' do
source_file('track.wav', fixture: '44100_16.wav', bpm: 120)
sync
assert_file_on_device 'track 120 bpm.wav'

Wavesync::Audio.new(File.join(@source_dir, 'track.wav')).write_bpm(130)
sync

assert_file_on_device 'track 130 bpm.wav'
assert_file_not_on_device 'track 120 bpm.wav'
end

test 'writes cue points from device wav to source wav when source has none' do
cue_points = [{ identifier: 1, sample_offset: 44_100, label: 'Marker' }]
source_file('track.wav', fixture: '44100_16.wav', cue_points: cue_points)
sync

FileUtils.cp(File.join(FIXTURES_PATH, '44100_16.wav'), File.join(@source_dir, 'track.wav'))
sync

source_cue_points = Wavesync::CueChunk.read(File.join(@source_dir, 'track.wav'))
assert_equal 1, source_cue_points.size
assert_equal 44_100, source_cue_points[0][:sample_offset]
assert_equal 'Marker', source_cue_points[0][:label]
end
end
end
Loading
Loading