diff --git a/config/devices.yml b/config/devices.yml index 5de4003..67edbcc 100644 --- a/config/devices.yml +++ b/config/devices.yml @@ -40,3 +40,4 @@ devices: - 44100 file_types: - mp3 + transliterate_metadata: true diff --git a/lib/wavesync/audio.rb b/lib/wavesync/audio.rb index 5d50c33..1c950df 100644 --- a/lib/wavesync/audio.rb +++ b/lib/wavesync/audio.rb @@ -69,6 +69,46 @@ def write_cue_points(cue_points) FileUtils.mv(temp_path, @file_path) end + ID3V2_FRAME_TITLE = 'TIT2' + ID3V2_FRAME_ARTIST = 'TPE1' + ID3V2_FRAME_ALBUM = 'TALB' + ID3V2_FRAME_ALBUM_ARTIST = 'TPE2' + ID3V2_FRAME_GENRE = 'TCON' + ID3V2_FRAME_COMPOSER = 'TCOM' + ID3V2_FRAME_ENCODED_BY = 'TENC' + ID3V2_FRAME_COMPILATION = 'TCMP' + + COMBINING_MARKS = /\p{Mn}/ + + TRANSLITERATE_FRAME_IDS = [ + ID3V2_FRAME_TITLE, + ID3V2_FRAME_ARTIST, + ID3V2_FRAME_ALBUM, + ID3V2_FRAME_ALBUM_ARTIST, + ID3V2_FRAME_GENRE, + ID3V2_FRAME_COMPOSER, + ID3V2_FRAME_ENCODED_BY, + ID3V2_FRAME_COMPILATION + ].freeze + + #: () -> void + def transliterate_tags + return unless @file_ext == '.mp3' + + TagLib::MPEG::File.open(@file_path) do |file| + tag = file.id3v2_tag + next if tag.nil? + + TRANSLITERATE_FRAME_IDS.each do |frame_id| + tag.frame_list(frame_id).each do |frame| + frame.text = transliterate(frame.to_string) + end + end + + file.save + end + end + #: (String | Integer | Float bpm) -> void def write_bpm(bpm) case @file_ext @@ -222,5 +262,12 @@ def write_id3v2_bpm(tag, bpm) frame.text = bpm.to_s tag.add_frame(frame) end + + #: (String string) -> String + def transliterate(string) + string + .unicode_normalize(:nfd) + .gsub(COMBINING_MARKS, '') + end end end diff --git a/lib/wavesync/device.rb b/lib/wavesync/device.rb index 1289f29..ed922f3 100644 --- a/lib/wavesync/device.rb +++ b/lib/wavesync/device.rb @@ -11,9 +11,10 @@ class Device attr_reader :bpm_source #: Symbol? attr_reader :bar_multiple #: Integer? attr_reader :unsupported_characters #: Array[String] + attr_reader :transliterate_metadata #: bool - #: (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String]) -> void - def initialize(name:, sample_rates:, file_types:, bit_depths: [], bpm_source: nil, bar_multiple: nil, unsupported_characters: []) + #: (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String], ?transliterate_metadata: bool) -> void + def initialize(name:, sample_rates:, file_types:, bit_depths: [], bpm_source: nil, bar_multiple: nil, unsupported_characters: [], transliterate_metadata: false) @name = name @sample_rates = sample_rates @bit_depths = bit_depths @@ -21,6 +22,7 @@ def initialize(name:, sample_rates:, file_types:, bit_depths: [], bpm_source: ni @bpm_source = bpm_source @bar_multiple = bar_multiple @unsupported_characters = unsupported_characters + @transliterate_metadata = transliterate_metadata end #: () -> String @@ -49,7 +51,8 @@ def self.load_from_yaml file_types: attrs['file_types'], bpm_source: attrs['bpm_source']&.to_sym, bar_multiple: attrs['bar_multiple'], - unsupported_characters: attrs['unsupported_characters'] || [] + unsupported_characters: attrs['unsupported_characters'] || [], + transliterate_metadata: attrs['transliterate_metadata'] || false ) end end diff --git a/lib/wavesync/scanner.rb b/lib/wavesync/scanner.rb index 0fea64d..085933b 100644 --- a/lib/wavesync/scanner.rb +++ b/lib/wavesync/scanner.rb @@ -60,6 +60,7 @@ def sync(target_library_path, device, pad: false) before_transcode: -> { @ui.conversion_progress(source_format, target_format) }) do |local_temp_path| inject_acid_bpm(local_temp_path, bpm, device) inject_cue_points(local_temp_path, audio, source_format, target_format) + inject_transliterated_metadata(local_temp_path, device) end path_resolver.resolve(file, audio, target_file_type: target_format.file_type) else @@ -76,7 +77,8 @@ def sync(target_library_path, device, pad: false) end else copied = copy_file(audio, file, path_resolver) - path_resolver.resolve(file, audio) + target_path = path_resolver.resolve(file, audio) + inject_transliterated_metadata(target_path.to_s, device) if copied end @ui.copy(source_format) end @@ -116,6 +118,13 @@ def copy_file(audio, source_file_path, path_resolver) end end + #: (String path, Device device) -> void + def inject_transliterated_metadata(path, device) + return unless device.transliterate_metadata + + Audio.new(path).transliterate_tags + end + #: (String local_temp_path, (Integer | Float | String)? bpm, Device device) -> void def inject_acid_bpm(local_temp_path, bpm, device) return unless device.bpm_source == :acid_chunk && bpm && File.extname(local_temp_path).downcase == '.wav' diff --git a/sig/generated/wavesync/audio.rbs b/sig/generated/wavesync/audio.rbs index 21ee643..2ebadc2 100644 --- a/sig/generated/wavesync/audio.rbs +++ b/sig/generated/wavesync/audio.rbs @@ -31,6 +31,29 @@ module Wavesync # : (Array[{identifier: Integer, sample_offset: Integer, label: String?}] cue_points) -> void def write_cue_points: (Array[{ identifier: Integer, sample_offset: Integer, label: String? }] cue_points) -> void + ID3V2_FRAME_TITLE: ::String + + ID3V2_FRAME_ARTIST: ::String + + ID3V2_FRAME_ALBUM: ::String + + ID3V2_FRAME_ALBUM_ARTIST: ::String + + ID3V2_FRAME_GENRE: ::String + + ID3V2_FRAME_COMPOSER: ::String + + ID3V2_FRAME_ENCODED_BY: ::String + + ID3V2_FRAME_COMPILATION: ::String + + COMBINING_MARKS: ::Regexp + + TRANSLITERATE_FRAME_IDS: untyped + + # : () -> void + def transliterate_tags: () -> void + # : (String | Integer | Float bpm) -> void def write_bpm: (String | Integer | Float bpm) -> void @@ -39,8 +62,8 @@ module Wavesync private - # : (Integer? target_sample_rate, Integer? target_bit_depth, ?Numeric? padding_seconds) -> Hash[Symbol, untyped] - def build_transcode_options: (Integer? target_sample_rate, Integer? target_bit_depth, ?Numeric? padding_seconds) -> Hash[Symbol, untyped] + # : (String target_file_type, Integer? target_bit_depth) -> String + def transcode_codec: (String target_file_type, Integer? target_bit_depth) -> String # : () -> (String | Integer)? def bpm_from_file: () -> (String | Integer)? @@ -80,5 +103,8 @@ module Wavesync # : (untyped tag, String | Integer | Float bpm) -> void def write_id3v2_bpm: (untyped tag, String | Integer | Float bpm) -> void + + # : (String string) -> String + def transliterate: (String string) -> String end end diff --git a/sig/generated/wavesync/device.rbs b/sig/generated/wavesync/device.rbs index fda167f..ef7a7d2 100644 --- a/sig/generated/wavesync/device.rbs +++ b/sig/generated/wavesync/device.rbs @@ -16,8 +16,10 @@ module Wavesync attr_reader unsupported_characters: Array[String] - # : (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String]) -> void - def initialize: (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String]) -> void + attr_reader transliterate_metadata: bool + + # : (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String], ?transliterate_metadata: bool) -> void + def initialize: (name: String, sample_rates: Array[Integer], file_types: Array[String], ?bit_depths: Array[Integer], ?bpm_source: Symbol?, ?bar_multiple: Integer?, ?unsupported_characters: Array[String], ?transliterate_metadata: bool) -> void # : () -> String def self.config_path: () -> String diff --git a/sig/generated/wavesync/scanner.rbs b/sig/generated/wavesync/scanner.rbs index b3ce122..ffabd5e 100644 --- a/sig/generated/wavesync/scanner.rbs +++ b/sig/generated/wavesync/scanner.rbs @@ -16,6 +16,9 @@ module Wavesync # : (Audio audio, String source_file_path, PathResolver path_resolver) -> bool def copy_file: (Audio audio, String source_file_path, PathResolver path_resolver) -> bool + # : (String path, Device device) -> void + def inject_transliterated_metadata: (String path, Device device) -> void + # : (String local_temp_path, (Integer | Float | String)? bpm, Device device) -> void def inject_acid_bpm: (String local_temp_path, (Integer | Float | String)? bpm, Device device) -> void diff --git a/sig/generated/wavesync/set_editor.rbs b/sig/generated/wavesync/set_editor.rbs index c0f7450..d871c10 100644 --- a/sig/generated/wavesync/set_editor.rbs +++ b/sig/generated/wavesync/set_editor.rbs @@ -43,12 +43,6 @@ module Wavesync # : (Float semitones) -> String def format_pitch_shift: (Float semitones) -> String - # : () -> void - def kill_player: () -> void - - # : () -> void - def check_player: () -> void - private # : () -> void @@ -105,9 +99,15 @@ module Wavesync # : () -> void def jump_to_next_cue: () -> void + # : () -> void + def kill_player: () -> void + # : () -> void def stop_playback: () -> void + # : () -> void + def check_player: () -> void + # : () -> void def advance_and_play: () -> void diff --git a/sig/taglib.rbs b/sig/taglib.rbs index e2db3db..8b71077 100644 --- a/sig/taglib.rbs +++ b/sig/taglib.rbs @@ -42,6 +42,7 @@ module TagLib class TextIdentificationFrame def initialize: (::String id, untyped encoding) -> void def text=: (::String text) -> ::String + def to_string: () -> ::String end class Tag diff --git a/test/wavesync/audio_test.rb b/test/wavesync/audio_test.rb index 7a872d0..2673740 100644 --- a/test/wavesync/audio_test.rb +++ b/test/wavesync/audio_test.rb @@ -64,6 +64,52 @@ class AudioTest < Wavesync::TestCase assert_nil audio('44100_16.aiff').bpm end + test 'transliterate_tags replaces umlauts across all supported id3v2 frames for mp3' do + with_temp_copy('44100.mp3') do |path| + write_id3v2_frames( + path, + 'TIT2' => 'Jóga', + 'TPE1' => 'Björk', + 'TALB' => 'Åström Remixes', + 'TPE2' => 'Sigur Rós', + 'TCON' => 'Électronique', + 'TCOM' => 'Jean-Michel Jarré', + 'TENC' => 'Encöder', + 'TCMP' => '1' + ) + + Audio.new(path).transliterate_tags + + frames = read_id3v2_frames(path) + assert_equal 'Joga', frames['TIT2'] + assert_equal 'Bjork', frames['TPE1'] + assert_equal 'Astrom Remixes', frames['TALB'] + assert_equal 'Sigur Ros', frames['TPE2'] + assert_equal 'Electronique', frames['TCON'] + assert_equal 'Jean-Michel Jarre', frames['TCOM'] + assert_equal 'Encoder', frames['TENC'] + assert_equal '1', frames['TCMP'] + end + end + + test 'transliterate_tags leaves ascii-only tags unchanged' do + with_temp_copy('44100.mp3') do |path| + write_id3v2_frames(path, 'TPE1' => 'Aphex Twin', 'TIT2' => 'Windowlicker') + + Audio.new(path).transliterate_tags + + frames = read_id3v2_frames(path) + assert_equal 'Aphex Twin', frames['TPE1'] + assert_equal 'Windowlicker', frames['TIT2'] + end + end + + test 'transliterate_tags is a no-op for non-mp3 files' do + with_temp_copy('44100_16.wav') do |path| + Audio.new(path).transliterate_tags + end + end + test 'write_bpm round-trips for wav via acid chunk' do with_temp_copy('44100_16.wav') do |path| Audio.new(path).write_bpm(128) @@ -155,6 +201,30 @@ def audio(name) Audio.new(File.join(FIXTURES_PATH, name)) end + def write_id3v2_frames(path, frames) + TagLib::MPEG::File.open(path) do |file| + tag = file.id3v2_tag(true) + frames.each do |frame_id, text| + tag.remove_frames(frame_id) + frame = TagLib::ID3v2::TextIdentificationFrame.new(frame_id, TagLib::String::UTF8) + frame.text = text + tag.add_frame(frame) + end + file.save + end + end + + def read_id3v2_frames(path) + result = {} + TagLib::MPEG::File.open(path) do |file| + tag = file.id3v2_tag + tag.frame_list.each do |frame| + result[frame.frame_id] = frame.to_string + end + end + result + end + def with_temp_copy(name) ext = File.extname(name) tmp = Tempfile.new(['audio_test', ext]) diff --git a/test/wavesync/device_test.rb b/test/wavesync/device_test.rb index 88392a9..153ec17 100644 --- a/test/wavesync/device_test.rb +++ b/test/wavesync/device_test.rb @@ -67,6 +67,20 @@ class DeviceTest < Wavesync::TestCase octatrack = Device.find_by(name: 'Octatrack') assert_includes octatrack.unsupported_characters, '"' end + test 'Playdate has transliterate_metadata enabled' do + playdate = Device.find_by(name: 'Playdate') + assert_equal true, playdate.transliterate_metadata + end + + test 'transliterate_metadata defaults to false' do + device = Device.new( + name: 'Test', + sample_rates: [44_100], + file_types: ['wav'] + ) + assert_equal false, device.transliterate_metadata + end + test 'unsupported_characters defaults to empty array' do device = Device.new( name: 'Test',