wavesync 1.0.0.alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-prompt'
4
+ require 'io/console'
5
+
6
+ module Wavesync
7
+ class SetEditor
8
+ KEY_MAP = {
9
+ 'a' => :add,
10
+ 'u' => :move_up,
11
+ 'd' => :move_down,
12
+ 'r' => :remove,
13
+ 's' => :save,
14
+ 'c' => :quit,
15
+ ' ' => :toggle_play,
16
+ "\e[A" => :cursor_up,
17
+ "\e[B" => :cursor_down
18
+ }.freeze
19
+
20
+ def initialize(set, library_path)
21
+ @set = set
22
+ @library_path = library_path
23
+ @prompt = TTY::Prompt.new(interrupt: :exit, active_color: :red)
24
+ @ui = UI.new
25
+ @selected = @set.tracks.empty? ? nil : 0
26
+ @player_pid = nil
27
+ @player_track = nil
28
+ @player_state = :stopped
29
+ @player_offset = 0
30
+ @player_started_at = nil
31
+ end
32
+
33
+ def run
34
+ loop do
35
+ check_player
36
+ render
37
+ action = KEY_MAP[read_key]
38
+ next unless action
39
+
40
+ result = handle_action(action)
41
+ break if result == :quit
42
+ end
43
+ ensure
44
+ stop_playback
45
+ end
46
+
47
+ private
48
+
49
+ def read_key
50
+ $stdin.raw do |io|
51
+ char = io.getc
52
+ if char == "\e"
53
+ rest = begin
54
+ io.read_nonblock(3)
55
+ rescue StandardError
56
+ ''
57
+ end
58
+ char + rest
59
+ else
60
+ char
61
+ end
62
+ end
63
+ end
64
+
65
+ def relative_path(absolute)
66
+ absolute.sub("#{@library_path}/", '')
67
+ end
68
+
69
+ def display_name(relative)
70
+ File.basename(relative, '.*').sub(/\A\d+[\s.\-_]+/, '')
71
+ end
72
+
73
+ def render(title = "wavesync set #{@set.name}")
74
+ @ui.clear
75
+ puts @ui.color(title, :primary)
76
+ puts
77
+
78
+ if @set.tracks.empty?
79
+ puts @ui.color(' (no tracks)', :secondary)
80
+ else
81
+ @set.tracks.each_with_index do |track, i|
82
+ render_track(i, relative_path(track), i == @selected, track == @player_track)
83
+ end
84
+ end
85
+
86
+ puts
87
+ puts @ui.color('[↑↓] navigate [space] play/pause [a] add [u] up [d] down [r] remove [s] save [c] cancel',
88
+ :secondary)
89
+ end
90
+
91
+ def render_track(index, relative, selected, playing)
92
+ name = display_name(relative)
93
+ folder = File.dirname(relative)
94
+ icon = if playing
95
+ @player_state == :paused ? '⏸' : '▶'
96
+ else
97
+ ' '
98
+ end
99
+ puts "#{@ui.color(icon, :surface)} #{@ui.color("#{index + 1}.", selected ? :highlight : :extra)} #{@ui.color(name, selected ? :highlight : :primary)}"
100
+ puts @ui.color(" #{folder}", selected ? :highlight : :tertiary) unless folder == '.'
101
+ end
102
+
103
+ def handle_action(action)
104
+ case action
105
+ when :cursor_up
106
+ @selected = [@selected - 1, 0].max unless @set.tracks.empty?
107
+ when :cursor_down
108
+ @selected = [@selected + 1, @set.tracks.size - 1].min unless @set.tracks.empty?
109
+ when :toggle_play then toggle_playback
110
+ when :add then add_track
111
+ when :remove then remove_track
112
+ when :move_up then move_track(:up)
113
+ when :move_down then move_track(:down)
114
+ when :save
115
+ @set.save
116
+ path = Set.set_path(@library_path, @set.name)
117
+ puts @ui.color("Saved '#{@set.name}' to #{path}.", :primary)
118
+ :quit
119
+ when :quit
120
+ :quit
121
+ end
122
+ end
123
+
124
+ def toggle_playback
125
+ return if @selected.nil?
126
+
127
+ track = @set.tracks[@selected]
128
+
129
+ if @player_track == track
130
+ case @player_state
131
+ when :playing
132
+ @player_offset += Time.now - @player_started_at
133
+ kill_player
134
+ @player_state = :paused
135
+ when :paused
136
+ start_player(track, @player_offset)
137
+ end
138
+ else
139
+ stop_playback
140
+ start_player(track)
141
+ end
142
+ end
143
+
144
+ def start_player(track, offset = 0)
145
+ ffplay = FFMPEG.ffmpeg_binary.sub('ffmpeg', 'ffplay')
146
+ args = [ffplay, '-nodisp', '-autoexit', '-loglevel', 'quiet', '-probesize', '32', '-analyzeduration', '0']
147
+ args += ['-ss', offset.to_s] if offset.positive?
148
+ args << track
149
+ @player_pid = spawn(*args, out: File::NULL, err: File::NULL)
150
+ @player_track = track
151
+ @player_offset = offset
152
+ @player_started_at = Time.now
153
+ @player_state = :playing
154
+ end
155
+
156
+ def kill_player
157
+ return unless @player_pid
158
+
159
+ Process.kill('TERM', @player_pid)
160
+ @player_pid = nil
161
+ rescue Errno::ESRCH
162
+ @player_pid = nil
163
+ end
164
+
165
+ def stop_playback
166
+ kill_player
167
+ @player_track = nil
168
+ @player_state = :stopped
169
+ @player_offset = 0
170
+ @player_started_at = nil
171
+ end
172
+
173
+ def check_player
174
+ return unless @player_pid
175
+
176
+ result = Process.waitpid(@player_pid, Process::WNOHANG)
177
+ return unless result
178
+
179
+ @player_pid = nil
180
+ @player_track = nil
181
+ @player_state = :stopped
182
+ @player_offset = 0
183
+ advance_and_play
184
+ rescue Errno::ECHILD
185
+ @player_pid = nil
186
+ @player_track = nil
187
+ @player_state = :stopped
188
+ @player_offset = 0
189
+ advance_and_play
190
+ end
191
+
192
+ def advance_and_play
193
+ return if @selected.nil? || @selected >= @set.tracks.size - 1
194
+
195
+ @selected += 1
196
+ start_player(@set.tracks[@selected])
197
+ end
198
+
199
+ def audio_files
200
+ @audio_files ||= Audio.find_all(@library_path)
201
+ end
202
+
203
+ def add_track
204
+ if audio_files.empty?
205
+ puts @ui.color('No audio files found in library.', :highlight)
206
+ sleep 1
207
+ return
208
+ end
209
+
210
+ choices = audio_files.map { |file| { name: relative_path(file), value: file } }
211
+
212
+ render("wavesync set #{@set.name} — add track")
213
+ puts
214
+ picked = @prompt.select('Select a track to add:', choices, cycle: true, filter: true, per_page: 20)
215
+
216
+ insert_at = @selected.nil? ? 0 : @selected + 1
217
+ @set.tracks.insert(insert_at, picked)
218
+ @selected = insert_at
219
+ end
220
+
221
+ def remove_track
222
+ return if @set.tracks.empty? || @selected.nil?
223
+
224
+ stop_playback if @player_track == @set.tracks[@selected]
225
+ @set.remove_track(@selected)
226
+ @selected = if @set.tracks.empty?
227
+ nil
228
+ else
229
+ [@selected, @set.tracks.size - 1].min
230
+ end
231
+ end
232
+
233
+ def move_track(direction)
234
+ return if @set.tracks.size < 2 || @selected.nil?
235
+
236
+ if direction == :up
237
+ @set.move_up(@selected)
238
+ @selected = [@selected - 1, 0].max
239
+ else
240
+ @set.move_down(@selected)
241
+ @selected = [@selected + 1, @set.tracks.size - 1].min
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavesync
4
+ class TrackPadding
5
+ BEATS_PER_BAR = 4
6
+
7
+ def self.compute(duration_seconds, bpm, bar_multiple)
8
+ return 0 if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0
9
+
10
+ seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
11
+ track_bars = (duration_seconds / seconds_per_bar).round(6)
12
+ target_bars = (track_bars / bar_multiple.to_f).ceil * bar_multiple
13
+
14
+ padding = (target_bars * seconds_per_bar) - duration_seconds
15
+ padding < 0.001 ? 0 : padding
16
+ end
17
+
18
+ def self.bar_counts(duration_seconds, bpm, bar_multiple)
19
+ return [nil, nil] if bpm.nil? || bpm.to_f.zero? || duration_seconds.nil? || duration_seconds <= 0
20
+
21
+ seconds_per_bar = BEATS_PER_BAR * 60.0 / bpm.to_f
22
+ original_bars = (duration_seconds / seconds_per_bar).round
23
+ target_bars = ((original_bars.to_f / bar_multiple).ceil * bar_multiple).to_i
24
+ [original_bars, target_bars]
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tty-cursor'
4
+ require 'rainbow'
5
+
6
+ module Wavesync
7
+ class UI
8
+ THEME = {
9
+ primary: :lightgray,
10
+ secondary: :darkgray,
11
+ tertiary: :dimgray,
12
+ highlight: :orangered,
13
+ surface: :hotpink,
14
+ extra: :deepskyblue
15
+ }.freeze
16
+
17
+ def initialize
18
+ @cursor = TTY::Cursor
19
+ @sticky_lines = []
20
+ end
21
+
22
+ def file_progress(filename)
23
+ path = Pathname.new(filename)
24
+ file_stem = path.basename(path.extname).to_s
25
+ parent_name = path.parent.basename.to_s
26
+ sticky(in_color(parent_name, :secondary), 1)
27
+ sticky(in_color(file_stem, :tertiary), 2)
28
+ end
29
+
30
+ def sync_progress(index, total_count, device)
31
+ parts = [
32
+ in_color("wavesync #{device.name}", :primary),
33
+ in_color("#{index + 1}/#{total_count}", :extra)
34
+ ]
35
+
36
+ sticky(parts.join(' '), 0)
37
+ end
38
+
39
+ def conversion_progress(source_format, target_format)
40
+ effective = source_format.merge(target_format)
41
+
42
+ source_info = audio_info(source_format.sample_rate, source_format.bit_depth)
43
+ target_info = audio_info(effective.sample_rate, effective.bit_depth)
44
+
45
+ formatted_line = in_color(
46
+ "Converting #{source_format.file_type} (#{source_info}) ⇢ #{effective.file_type} (#{target_info})", :highlight
47
+ )
48
+ sticky(formatted_line, 3)
49
+ end
50
+
51
+ def copy(source_format)
52
+ info = audio_info(source_format.sample_rate, source_format.bit_depth)
53
+
54
+ sticky(in_color("Copying #{source_format.file_type} (#{info})", :highlight), 3)
55
+ end
56
+
57
+ def skip
58
+ sticky(in_color('↷ Skipping, already synced', :highlight), 3)
59
+ end
60
+
61
+ def bpm(tbpm, original_bars: nil, target_bars: nil)
62
+ if tbpm.nil?
63
+ sticky('', 4)
64
+ elsif original_bars && target_bars
65
+ bar_info = original_bars == target_bars ? "#{original_bars} bars" : "#{original_bars} → #{target_bars} bars"
66
+ sticky("#{tbpm} bpm · #{bar_info}", 4)
67
+ else
68
+ sticky("#{tbpm} bpm", 4)
69
+ end
70
+ end
71
+
72
+ def analyze_progress(index, total_count)
73
+ parts = [
74
+ in_color('wavesync analyze', :primary),
75
+ in_color("#{index + 1}/#{total_count}", :extra)
76
+ ]
77
+ sticky(parts.join(' '), 0)
78
+ end
79
+
80
+ def analyze_skip(file, bpm)
81
+ set_analyze_file_stickies(file, in_color("↷ #{bpm} BPM already set", :highlight))
82
+ end
83
+
84
+ def analyze_result(file, bpm)
85
+ label = bpm ? in_color("#{bpm} BPM", :highlight) : in_color('No BPM detected', :highlight)
86
+ set_analyze_file_stickies(file, label)
87
+ end
88
+
89
+ def color(text, key)
90
+ in_color(text, key)
91
+ end
92
+
93
+ def clear
94
+ print @cursor.clear_screen
95
+ print @cursor.move_to(0, 0)
96
+ end
97
+
98
+ private
99
+
100
+ def audio_info(sample_rate, bit_depth)
101
+ [
102
+ sample_rate_to_khz(sample_rate),
103
+ bit_depth
104
+ ].compact.join('/')
105
+ end
106
+
107
+ def in_color(string, key)
108
+ Rainbow(string).color(THEME[key])
109
+ end
110
+
111
+ def sticky(text, index)
112
+ set_sticky(text, index)
113
+ redraw
114
+ end
115
+
116
+ def set_sticky(text, index)
117
+ @sticky_lines[index] = text
118
+ end
119
+
120
+ def set_analyze_file_stickies(file, label)
121
+ path = Pathname.new(file)
122
+ set_sticky(in_color(path.parent.basename.to_s, :secondary), 1)
123
+ set_sticky(in_color(path.basename(path.extname).to_s, :tertiary), 2)
124
+ set_sticky(label, 3)
125
+ redraw
126
+ end
127
+
128
+ def redraw
129
+ print @cursor.clear_screen
130
+ print @cursor.move_to(0, 0)
131
+ puts @sticky_lines.join("\n")
132
+ end
133
+
134
+ def sample_rate_to_khz(rate)
135
+ khz = rate.to_f / 1000
136
+ (khz % 1).zero? ? khz.to_i.to_s : khz.round(1).to_s
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavesync
4
+ VERSION = '1.0.0.alpha1'
5
+ end
data/lib/wavesync.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wavesync
4
+ end
5
+
6
+ require 'wavesync/version'
7
+ require 'wavesync/acid_chunk'
8
+ require 'wavesync/audio_format'
9
+ require 'wavesync/audio'
10
+ require 'wavesync/track_padding'
11
+ require 'wavesync/config'
12
+ require 'wavesync/device'
13
+ require 'wavesync/ui'
14
+ require 'wavesync/path_resolver'
15
+ require 'wavesync/file_converter'
16
+ require 'wavesync/scanner'
17
+ require 'wavesync/bpm_detector'
18
+ require 'wavesync/analyzer'
19
+ require 'wavesync/set'
20
+ require 'wavesync/set_editor'
21
+ require 'wavesync/cli'
metadata ADDED
@@ -0,0 +1,144 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wavesync
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0.alpha1
5
+ platform: ruby
6
+ authors:
7
+ - Andreas Zecher
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rainbow
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: streamio-ffmpeg
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: taglib-ruby
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tty-cursor
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.7.1
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: 0.7.1
82
+ - !ruby/object:Gem::Dependency
83
+ name: tty-prompt
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.23'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.23'
96
+ executables:
97
+ - wavesync
98
+ extensions: []
99
+ extra_rdoc_files: []
100
+ files:
101
+ - LICENSE
102
+ - README.md
103
+ - bin/wavesync
104
+ - config/devices.yml
105
+ - lib/wavesync.rb
106
+ - lib/wavesync/acid_chunk.rb
107
+ - lib/wavesync/analyzer.rb
108
+ - lib/wavesync/audio.rb
109
+ - lib/wavesync/audio_format.rb
110
+ - lib/wavesync/bpm_detector.rb
111
+ - lib/wavesync/cli.rb
112
+ - lib/wavesync/config.rb
113
+ - lib/wavesync/device.rb
114
+ - lib/wavesync/file_converter.rb
115
+ - lib/wavesync/path_resolver.rb
116
+ - lib/wavesync/scanner.rb
117
+ - lib/wavesync/set.rb
118
+ - lib/wavesync/set_editor.rb
119
+ - lib/wavesync/track_padding.rb
120
+ - lib/wavesync/ui.rb
121
+ - lib/wavesync/version.rb
122
+ homepage: https://github.com/pixelate/wavesync
123
+ licenses:
124
+ - MIT
125
+ metadata: {}
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '3.0'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 4.0.3
141
+ specification_version: 4
142
+ summary: Sync your music library to hardware devices like the teenage engineering
143
+ TP-7 and Elektron Octatrack
144
+ test_files: []