playnote 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 62a214c59bec51ba07ed57311fa9dc2c03a891ed1735e8e3a8010a4f0ff7e815
4
+ data.tar.gz: ee76f5db51cac164310d97738bf3ebe5d5f44f0a46b4e174a4677af09346be71
5
+ SHA512:
6
+ metadata.gz: 5f332337bc9ea437b642c714968279ba4c979aa81ba6e565aedf0ffce221a42841b42efbce6c13df05fbd14885e1f758c21a4b46f0395a0b1fc5c54cb7121347
7
+ data.tar.gz: e002225bbf5bc59ef716ea9130e9bd8eff2b4e0162317d80c36c4710fb4fb0e21dbd810e601e78af21cf902b113d068f4aa940238cdc625a3cd602a23d598d77
data/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2025 asciodev
2
+
3
+ Permission is hereby granted, free of charge, to any person
4
+ obtaining a copy of this software and associated documentation
5
+ files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use,
7
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the
9
+ Software is furnished to do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be
13
+ included in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # Playnote
2
+
3
+ Playnote is a utility for ergonomically playing tones from the CLI.
4
+
5
+ ## Dependencies
6
+
7
+ * Ruby 3.4 or later
8
+ * ffplay 7.1.1 or later
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ gem install playnote
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```manpage
19
+ Usage: playnote [options] <notes>
20
+
21
+ Playnote is a utility for ergonomically playing tones from the CLI.
22
+
23
+ Each note is a string taking the form <tone>[semitone][octave], with
24
+ sharps denoted by `#`, and flats denoted by `%`. Both semitones and octaves
25
+ are optional; by default, notes are neither flat nor sharp, and each inherits
26
+ the octave of the previous note if its octave is unspecified. (If no notes
27
+ specify an octave, the octave for all notes is 0.) There is no delimiter character.
28
+
29
+ -q, --quiet Suppresses all terminal output from command
30
+ -p, --print-only Prints the given notes with their frequencies,
31
+ without audio playback (overrides -q)
32
+ -d, --duration [DURATION] Time in seconds to play the given notes
33
+ -h, --help Prints this message and exits
34
+ -v, --[no-]verbose Run verbosely (overrides -q and -p)
35
+
36
+ EXAMPLES
37
+ playnote c3eg Play a C Major Chord in the 3rd Octave
38
+
39
+ playnote c3e%4g Play a C Minor Chord, with C in 3rd Octave,
40
+ and E♭ and G in the 4th
41
+
42
+ playnote g#5 Play a G♯ Note in the 5th Octave
43
+
44
+ playnote -p f2ac Print the notes comprising an F Major chord
45
+ along with their frequencies, and exit
46
+ without audio playback
47
+ ```
48
+
49
+ ## Contributing
50
+
51
+ Bug reports and pull requests are welcome on GitHub at https://github.com/asciodev/playnote.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
data/exe/playnote ADDED
@@ -0,0 +1,280 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ require 'curses'
4
+
5
+ Note = Struct.new(:letter, :semitone, :octave, :frequency, :pretty, :original)
6
+
7
+ class NotePlayer
8
+ @@hz_map = {
9
+ "C0": 16.35,
10
+ "C#0": 17.32,
11
+ "D0": 18.35,
12
+ "E%0": 19.45,
13
+ "E0": 20.6,
14
+ "F0": 21.83,
15
+ "F#0": 23.12,
16
+ "G0": 24.5,
17
+ "A%0": 25.96,
18
+ "A0": 27.5,
19
+ "B%0": 29.14,
20
+ "B0": 30.87,
21
+ "C1": 32.7,
22
+ "C#1": 34.65,
23
+ "D1": 36.71,
24
+ "E%1": 38.89,
25
+ "E1": 41.2,
26
+ "F1": 43.65,
27
+ "F#1": 46.25,
28
+ "G1": 49,
29
+ "A%1": 51.91,
30
+ "A1": 55,
31
+ "B%1": 58.27,
32
+ "B1": 61.74,
33
+ "C2": 65.41,
34
+ "C#2": 69.3,
35
+ "D2": 73.42,
36
+ "E%2": 77.78,
37
+ "E2": 82.41,
38
+ "F2": 87.31,
39
+ "F#2": 92.5,
40
+ "G2": 98,
41
+ "A%2": 103.83,
42
+ "A2": 110,
43
+ "B%2": 116.54,
44
+ "B2": 123.47,
45
+ "C3": 130.81,
46
+ "C#3": 138.59,
47
+ "D3": 146.83,
48
+ "E%3": 155.56,
49
+ "E3": 164.81,
50
+ "F3": 174.61,
51
+ "F#3": 185,
52
+ "G3": 196,
53
+ "A%3": 207.65,
54
+ "A3": 220,
55
+ "B%3": 233.08,
56
+ "B3": 246.94,
57
+ "C4": 261.63,
58
+ "C#4": 277.18,
59
+ "D4": 293.66,
60
+ "E%4": 311.13,
61
+ "E4": 329.63,
62
+ "F4": 349.23,
63
+ "F#4": 369.99,
64
+ "G4": 392,
65
+ "A%4": 415.3,
66
+ "A4": 440,
67
+ "B%4": 466.16,
68
+ "B4": 493.88,
69
+ "C5": 523.25,
70
+ "C#5": 554.37,
71
+ "D5": 587.33,
72
+ "E%5": 622.25,
73
+ "E5": 659.25,
74
+ "F5": 698.46,
75
+ "F#5": 739.99,
76
+ "G5": 783.99,
77
+ "A%5": 830.61,
78
+ "A5": 880,
79
+ "B%5": 932.33,
80
+ "B5": 987.77,
81
+ "C6": 1046.5,
82
+ "C#6": 1108.73,
83
+ "D6": 1174.66,
84
+ "E%6": 1244.51,
85
+ "E6": 1318.51,
86
+ "F6": 1396.91,
87
+ "F#6": 1479.98,
88
+ "G6": 1567.98,
89
+ "A%6": 1661.22,
90
+ "A6": 1760,
91
+ "B%6": 1864.66,
92
+ "B6": 1975.53,
93
+ "C7": 2093,
94
+ "C#7": 2217.46,
95
+ "D7": 2349.32,
96
+ "E%7": 2489.02,
97
+ "E7": 2637.02,
98
+ "F7": 2793.83,
99
+ "F#7": 2959.96,
100
+ "G7": 3135.96,
101
+ "A%7": 3322.44,
102
+ "A7": 3520,
103
+ "B%7": 3729.31,
104
+ "B7": 3951.07,
105
+ "C8": 4186.01,
106
+ "C#8": 4434.92,
107
+ "D8": 4698.63,
108
+ "E%8": 4978.03,
109
+ "E8": 5274.04,
110
+ "F8": 5587.65,
111
+ "F#8": 5919.91,
112
+ "G8": 6271.93,
113
+ "A%8": 6644.88,
114
+ "A8": 7040,
115
+ "B%8": 7458.62,
116
+ "B8": 7902.13
117
+ }
118
+
119
+ def initialize(args=ARGV)
120
+ @pids = []
121
+ @duration = :infinite
122
+ @options = {duration: @duration}
123
+ @optionsparser = OptionParser.new do |opts|
124
+ opts.banner = "Usage: #{opts.program_name} [options] <notes>"
125
+ opts.on_tail("\nAUTHORS", " asciodev")
126
+ opts.on("\nPlaynote is a utility for ergonomically playing tones from the CLI.")
127
+ opts.on("\nEach note is a string taking the form <tone>[semitone][octave], with")
128
+ opts.on("sharps denoted by `#`, and flats denoted by `%`. Both semitones and octaves")
129
+ opts.on("are optional; by default, notes are neither flat nor sharp, and each inherits")
130
+ opts.on("the octave of the previous note if its octave is unspecified. (If no notes")
131
+ opts.on("specify an octave, the octave for all notes is 0.) There is no delimiter character.")
132
+ opts.on("")
133
+ opts.on("-q", "--quiet", FalseClass, "Suppresses all terminal output from command") { @options[:quiet] = @quiet = true }
134
+ opts.on("-p", "--print-only", FalseClass, "Prints the given notes with their frequencies,", "without audio playback (overrides -q)") { @options[:print_only] = @print_only = true }
135
+ opts.on("-d", "--duration [DURATION]", Float, "Time in seconds to play the given notes") { @options[:duration] = @duration = it }
136
+ opts.on("-h", "--help", "Prints this message and exits") { @options[:help] = true }
137
+ opts.on("-v", "--[no-]verbose", FalseClass, "Run verbosely (overrides -q and -z)") { @options[:verbose] = @verbose = true }
138
+ opts.on("-V", "--version", "Prints the currently running version of #{opts.program_name}", "and exits") { @options[:version] = @version = true }
139
+ opts.on(
140
+ "\nEXAMPLES",
141
+ sprintf(" %-#{opts.summary_width}s %s", "#{opts.program_name} c3eg", "Play a C Major Chord in the 3rd Octave"),
142
+ sprintf("\n %-#{opts.summary_width}s %s", "#{opts.program_name} c3e%4g", "Play a C Minor Chord, with C in 3rd Octave,"),
143
+ sprintf(" %-#{opts.summary_width}s %s", "", "and E♭ and G in the 4th"),
144
+ sprintf("\n %-#{opts.summary_width}s %s", "#{opts.program_name} g#5", "Play a G♯ Note in the 5th Octave"),
145
+ sprintf("\n %-#{opts.summary_width}s %s", "#{opts.program_name} -p f2ac", "Print the notes comprising an F Major chord"),
146
+ sprintf(" %-#{opts.summary_width}s %s", "", "along with their frequencies, and exit"),
147
+ sprintf(" %-#{opts.summary_width}s %s", "", "without audio playback"),
148
+ )
149
+ @raw_notes = opts.permute(args)
150
+ end
151
+ version! if @options[:version]
152
+ help! if @options[:help]
153
+ if(@raw_notes.is_a?(Array) && @raw_notes.first.is_a?(String))
154
+ @options[:notes] = @raw_notes.first.scan(/[a-gA-G][#%nN]?\d?/).map(&:downcase)
155
+ else
156
+ help!
157
+ end
158
+ @optionsparser.parse!
159
+ octave = 0
160
+ @notes = @options[:notes].map do
161
+ note = it.upcase
162
+ _has_octave = _octave = note[/\d$/]
163
+ octave = _octave if _has_octave
164
+ note = "#{note}#{octave}" unless _has_octave
165
+ hz = @@hz_map[note.to_sym]
166
+ if hz
167
+ Note.new(note[/^[a-gA-G]/], note[/^[a-gA-G]([#%nN])/, 1], note[/\d$/], hz, pretty_note(note), pretty_note(it))
168
+ else
169
+ alt = alternate_note(note)
170
+ hz = @@hz_map[alternate_note(note).to_sym]
171
+ Note.new(alt[/^[a-gA-G]/], alt[/^[a-gA-G]([#%nN])/, 1], alt[/\d$/], hz, pretty_note(alt), pretty_note(it))
172
+ end
173
+ end
174
+ end
175
+
176
+ def show_message
177
+ message = self.to_s
178
+ Curses::init_screen
179
+ msgwidth = message.lines.map(&:length).max
180
+ msgheight = @notes.length + 1
181
+ winwidth = msgwidth + 6
182
+ winheight = msgheight * 3
183
+ current_line = 0
184
+ win = Curses::Window.new(winheight, winwidth, (Curses::lines - winheight) / 2, (Curses::cols - winwidth) / 2)
185
+ win.box(?|, ?-)
186
+ message.lines.each do |it|
187
+ win.setpos(current_line + (winheight - msgheight) / 2, 1 + (winwidth - msgwidth) / 2)
188
+ win.addstr "#{it.strip}"
189
+ current_line += 1
190
+ end
191
+ Curses::curs_set(0)
192
+ win.refresh
193
+ Curses::cbreak
194
+ win.getch
195
+ Curses::curs_set(1)
196
+ win.close
197
+ Curses::nocbreak
198
+ end
199
+
200
+ def play
201
+ puts self if !@quiet || @print_only || @verbose
202
+ ffargs = [
203
+ "ffplay",
204
+ "-autoexit",
205
+ "-nodisp",
206
+ "-loglevel",
207
+ (@verbose ? "verbose" : "error"),
208
+ (@verbose ? nil : "-hide_banner"),
209
+ "-f", "lavfi",
210
+ "-i", ffplay_cmd_str,
211
+ [:out, :err] => (@verbose ? :out : File::NULL)
212
+ ].reject(&:nil?)
213
+ puts ffargs.inspect if @verbose
214
+ return if @print_only
215
+ @pids.push spawn(*ffargs)
216
+ show_message
217
+ until @pids.empty?
218
+ pid = @pids.pop
219
+ Process.kill "TERM", pid
220
+ Process.wait pid
221
+ end
222
+ exit
223
+ end
224
+
225
+ def inspect
226
+ to_str
227
+ end
228
+
229
+ def to_str
230
+ @notes.map { sprintf "%-5s %-9s %d", it.pretty, it.original, it.frequency }.join("\n").then do
231
+ [sprintf("%-5s %-9s %s", "note", "original", "frequency"), it].join("\n")
232
+ end
233
+ end
234
+
235
+ def to_s
236
+ to_str
237
+ end
238
+
239
+ private
240
+
241
+ def version!
242
+ printf "%s v%s\n", @optionsparser.program_name, Gem.loaded_specs["playnote"].version
243
+ exit
244
+ end
245
+
246
+ def help!
247
+ puts @optionsparser
248
+ exit
249
+ end
250
+
251
+ def alternate_note(note)
252
+ return note if note.length < 2 || @@hz_map[note.to_sym]
253
+ flat = note[1] == "%"
254
+ sharp = note[1] == "#"
255
+ letter = note[0]
256
+ octave = note[2..-1].to_i
257
+ return note unless flat || sharp
258
+ step = (flat ? -1 : 0) + (sharp ? 1 : 0) + 0
259
+ scale = ("A".."G").to_a
260
+ return "#{scale[(scale.index(letter) + step) % scale.length]}#{flat ? "#" : "%"}#{octave + step}"
261
+ end
262
+
263
+ def pretty_note(note)
264
+ note.gsub("#","♯").gsub("%","♭").gsub(/[nN]/, "♮")
265
+ end
266
+
267
+ def ffplay_cmd_str
268
+ alphabet = ("a".."z").to_a
269
+ arg_count = @notes.reject{it.frequency.nil?}.length
270
+ raise "Too many notes!" if arg_count > 26
271
+ @notes.each_with_index.map {|note,n| note.frequency ? "sine=f=#{note.frequency.to_i}[#{alphabet[n]}];" : ""}.join("").then do
272
+ used_letters = alphabet[0..arg_count-1]
273
+ dirstr = @duration == :infinite ? "" : "=end=#@duration"
274
+ [it, used_letters.map{"[#{it}]"}, "amerge=inputs=#{arg_count},atrim", dirstr].join("")
275
+ end
276
+ end
277
+
278
+ end
279
+
280
+ NotePlayer.new().play if(caller.empty? || caller&.last&.match(/bin\/playnote/))
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Playnote
4
+ VERSION = "0.1.0"
5
+ end
data/lib/playnote.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "playnote/version"
4
+
5
+ module Playnote
6
+ class Error < StandardError; end
7
+ # Your code goes here...
8
+ end
data/sig/playnote.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Playnote
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,49 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: playnote
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - asciodev
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ email:
13
+ - 81930475+asciodev@users.noreply.github.com
14
+ executables:
15
+ - playnote
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - LICENSE.md
20
+ - README.md
21
+ - Rakefile
22
+ - exe/playnote
23
+ - lib/playnote.rb
24
+ - lib/playnote/version.rb
25
+ - sig/playnote.rbs
26
+ homepage: https://github.com/asciodev/playnote
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ homepage_uri: https://github.com/asciodev/playnote
31
+ source_code_uri: https://github.com/asciodev/playnote
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 3.1.0
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubygems_version: 3.6.7
47
+ specification_version: 4
48
+ summary: Playnote is a utility for ergonomically playing tones from the CLI.
49
+ test_files: []