diff --git a/examples/clapping_music.rb b/examples/clapping_music.rb index c8e3185..436951f 100644 --- a/examples/clapping_music.rb +++ b/examples/clapping_music.rb @@ -19,6 +19,6 @@ tempo! 172 midi_percussion_ o2 set_note_length 8 - v1; s{ play }*(12*13) + v1; s{ play }*12*13 v2; 13.times { s{ play; pattern.rotate! }*12 } end.play diff --git a/lib/alda-rb.rb b/lib/alda-rb.rb index 04e4044..c93b3f8 100644 --- a/lib/alda-rb.rb +++ b/lib/alda-rb.rb @@ -1,12 +1,13 @@ require 'readline' require 'set' +require 'stringio' require 'irb/ruby-lex' require 'alda-rb/version' { Array => -> { "[#{map(&:to_alda_code).join ' '}]" }, Hash => -> { "{#{to_a.reduce(:+).map(&:to_alda_code).join ' '}}" }, - String => -> { inspect }, + String => -> { dump }, Symbol => -> { ?: + to_s }, Numeric => -> { inspect }, Range => -> { "#{first}-#{last}" }, @@ -26,19 +27,23 @@ def * n end end +class StringIO + # Equivalent to #string. + def to_s + string + end +end + # The module serving as a namespace. module Alda - # The path to the +alda+ executable. + # The array of available subcommands of alda executable. # - # The default value is "alda", - # which will depend on your PATH. - singleton_class.attr_accessor :executable - @executable = 'alda' - - # The method give Alda# ability to invoke +alda+ at the command line, - # using +name+ as subcommand and +args+ as arguments. - # +opts+ are converted to command line options. + # Alda# is able to invoke +alda+ at the command line. + # The subcommand is the name of the method invoked upon Alda#. + # + # The first argument (a hash) is interpreted as the options. + # The keyword arguments are interpreted as the subcommand options. # # The return value is the string output by the command in STDOUT. # @@ -48,197 +53,47 @@ module Alda # # => "Client version: 1.4.0\nServer version: [27713] 1.4.0\n" # Alda.parse code: 'bassoon: o3 c' # # => "{\"chord-mode\":false,\"current-instruments\":...}\n" - # Alda.sandwich - # # Alda::CommandLineError (Expected a command, got sandwich) - def self.method_missing name, *args, **opts - name = name.to_s.gsub ?_, ?- - opts.each do |key, val| - args.push "--#{key.to_s.gsub ?_, ?-}", val.to_s - end - output = IO.popen [executable, name, *args], &:read - raise CommandLineError.new $?, output if $?.exitstatus.nonzero? - output + COMMANDS = %i[ + help update repl up start_server init down stop_server + downup restart_server list status version play stop parse + instruments export + ].freeze + + COMMANDS.each do |command| + define_method command do |options = {}, **command_options| + args = [] + block = ->key, val { args.push "--#{key.to_s.tr ?_, ?-}", val.to_s } + options.each &block + args.push command.to_s + command_options.each &block + output = IO.popen [Alda.executable, *args], &:read + raise CommandLineError.new $?, output if $?.exitstatus.nonzero? + output + end end + # The path to the +alda+ executable. + # + # The default value is "alda", + # which will depend on your PATH. + singleton_class.attr_accessor :executable + @executable = 'alda' + # @return Whether the alda server is up. - def self.up? + def up? status.include? 'up' end # @return Whether the alda server is down. - def self.down? + def down? status.include? 'down' end + module_function :up?, :down?, *COMMANDS + # Start a REPL session. def self.repl - REPLSession.run - end - - # An encapsulation for the REPL session for alda-rb. - module REPLSession - - # Initialization. It is called automatically when starting - # the session if it has not been executed yet. - def self.init - @score = Score.new - @lex = RubyLex.new - @prompt = '' - @initialized = true - end - - # Start a session. The main loop is not included here. - # Sets @need_print to true. - def self.start - init unless @initialized - if Alda.down? - puts 'Starting Alda server...' - Alda.up - end - @io = IO.popen [Alda.executable, 'repl'], 'r+' - @need_print = false - nil - end - - # Runs the session. Includes the start, the main loop, and the termination. - def self.run - start - while scan_for_prompt - case process_rb_code rb_code - when :break - break - when :redo - redo - when :next - next - end - end - terminate - end - - # Scans for the next input prompt in the alda repl session. - # Outputs the scaned result if @need_print is true. - # Sets @need_print to true. - def self.scan_for_prompt - caught = '' - result = @io.each_char.each_cons 2 do |last, current| - caught.concat last - if last == ?> && current == ' ' - caught.concat current - @prompt = caught.lines(chomp: true).last - caught[-1] = '' until caught[-1] == ?\n || caught.empty? - break true - elsif last == ?o && current == ?\n - @io.print ?y - return false - end - end - result = false if result != true - $stdout.print caught if @need_print - @need_print = true - result - end - - # Reads the next Ruby codes input in the REPL session. - # It can intelligently continue reading if the code is not complete yet. - def self.rb_code - result = '' - begin - @io.print '' - buf = Readline.readline @prompt, true - return unless buf - result.concat buf, ?\n - ltype, indent, continue, block_open = @lex.check_state result - rescue Interrupt - $stdout.puts - retry - end while ltype || indent.nonzero? || continue || block_open - result - end - - # Processes the Ruby codes read. - # Sending it to a score and sending the result to alda. - # @return One of :break, :next, or :redo. - def self.process_rb_code code - return :break unless code - unless code[0] == ?: - @score.clear - begin - @score.get_binding.eval code - rescue StandardError, ScriptError => e - $stderr.print e.full_message - rescue Interrupt - return :redo - rescue SystemExit - return :break - end - code = @score.events_alda_codes - $stdout.puts code - end - @io.puts code - @io.gets - :next - end - - # Termination of the REPL session. - def self.terminate - @io.close - Readline::HISTORY.clear - end - end - - # The error is raised when one tries to - # run a non-existing subcommand of +alda+. - class CommandLineError < Exception - - # The Process::Status object representing the status of - # the process that runs +alda+ command. - attr_reader :status - - # Create a CommandLineError# object. - # @param status The status of the process running +alda+ command. - # @param msg The exception message. - def initialize status, msg = nil - super /ERROR\s*(?.*)$/ =~ msg ? message : msg&.lines(chomp: true).first - @status = status - end - end - - # This error is raised when one tries to - # append events in an EventList# in a wrong order. - # @example - # Alda::Score.new do - # motif = f4 f e e d d c2 - # g4 f e d c2 # It commented out, error will not occur - # c4 c g g a a g2 motif # OrderError - # end - class OrderError < Exception - - # The expected element gotten if it is of the correct order. - # @see #got - # @example - # Alda::Score.new do - # motif = f4 f e e d d c2 - # g4 f e d c2 - # p @events.size # => 2 - # c4 c g g a a g2 motif - # rescue OrderError => e - # p @events.size # => 1 - # p e.expected # => # - # p e.got # => # - # end - attr_reader :expected - - # The actually gotten element. - # For an example, see #expected. - # @see #expected - attr_reader :got - - def initialize expected, got - super 'events are out of order' - @expected = expected - @got = got - end + REPL.new.run end # Including this module can make your class have the ability @@ -416,7 +271,6 @@ class Score # Alda::Score.new { piano_; c; d; e }.play from: 1 # # (plays only an E note) def play **opts - Alda.stop Alda.play code: self, **opts end @@ -438,10 +292,24 @@ def export **opts Alda.export code: self, **opts end + # Saves the alda codes into a file. + def save filename + File.open(filename, 'w') { _1.puts to_s } + end + + # Loads alda codes from a file. + def load filename + event = InlineLisp.new :alda_code, File.read(filename) + @events.push event + event + end + + # @return Alda codes. def to_s events_alda_codes end + # The initialization. def initialize(...) super on_contained @@ -453,10 +321,182 @@ def clear @variables.clear self end + end + + # An encapsulation for the REPL session for alda-rb. + class REPL + + # The score object used in REPL. + # Includes Alda#, so it can refer to alda commandline. + class TempScore < Score + include Alda + + Score.instance_methods(false).each do |meth| + define_method meth, Score.instance_method(meth) + end + + def initialize session + super() + @session = session + end + + def to_s + history + end + + def history + @session.history.to_s + end + + def get_binding + binding + end + + alias quit exit + end + + # The history. + attr_reader :history + + # Initialization. + def initialize + @score = TempScore.new self + @binding = @score.get_binding + @lex = RubyLex.new + @history = StringIO.new + end + + # Runs the session. Includes the start, the main loop, and the termination. + def run + start + while code = rb_code + break unless process_rb_code code + end + terminate + end + + # Starts the session. + def start + end + + # Reads the next Ruby codes input in the REPL session. + # It can intelligently continue reading if the code is not complete yet. + def rb_code + result = '' + begin + buf = Readline.readline '> ', true + return unless buf + result.concat buf, ?\n + ltype, indent, continue, block_open = @lex.check_state result + rescue Interrupt + $stdout.puts + retry + end while ltype || indent.nonzero? || continue || block_open + result + end - # @return A Binding# object. - def get_binding - binding + # Processes the Ruby codes read. + # Sending it to a score and sending the result to alda. + # @return +true+ for continue looping, +false+ for breaking the loop. + def process_rb_code code + @score.clear + begin + @binding.eval code + rescue StandardError, ScriptError => e + $stderr.print e.full_message + return true + rescue Interrupt + return true + rescue SystemExit + return false + end + code = @score.events_alda_codes + unless code.empty? + $stdout.puts code + play_score code + end + true + end + + # Tries to run the block and rescue CommandLineError#. + def try_command # :block: + begin + yield + rescue CommandLineError => e + puts e + end + end + + # Plays the score. + def play_score code + try_command do + Alda.play code: code, history: @history + @history.puts code + end + end + + # Terminates the REPL session. + def terminate + clear_history + end + + # Clears the history. + def clear_history + @history = StringIO.new + end + end + + # The error is raised when one tries to + # run a non-existing subcommand of +alda+. + class CommandLineError < Exception + + # The Process::Status object representing the status of + # the process that runs +alda+ command. + attr_reader :status + + # Create a CommandLineError# object. + # @param status The status of the process running +alda+ command. + # @param msg The exception message. + def initialize status, msg = nil + super /ERROR\s*(?.*)$/ =~ msg ? message : msg&.lines(chomp: true).first + @status = status + end + end + + # This error is raised when one tries to + # append events in an EventList# in a wrong order. + # @example + # Alda::Score.new do + # motif = f4 f e e d d c2 + # g4 f e d c2 # It commented out, error will not occur + # c4 c g g a a g2 motif # OrderError + # end + class OrderError < Exception + + # The expected element gotten if it is of the correct order. + # @see #got + # @example + # Alda::Score.new do + # motif = f4 f e e d d c2 + # g4 f e d c2 + # p @events.size # => 2 + # c4 c g g a a g2 motif + # rescue OrderError => e + # p @events.size # => 1 + # p e.expected # => # + # p e.got # => # + # end + attr_reader :expected + + # The actually gotten element. + # For an example, see #expected. + # @see #expected + attr_reader :got + + def initialize expected, got + super 'events are out of order' + @expected = expected + @got = got end end @@ -546,7 +586,7 @@ def to_alda_code # Marks repetition. def * num - @count = num + @count = (@count || 1) * num self end