Artifact 17dedf68e18e88d2a7ae8bfbdd545f34cdf4bc3df3f71e60de2a6dd6bb212dc8:

  • File src/main.cr — part of check-in [271c1687b4] at 2025-01-14 07:20:15 on branch trunk — Copyright update (user: alexa size: 15718)

#### Benben
#### Copyright (C) 2023-2025 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or
#### modify it under the terms of the GNU Affero General Public
#### License as published by the Free Software Foundation, either
#### version 3 of the License, or (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#### Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.

###
### Requires
###
### Load order here is rather important, so we don't just do `require ./*` and
### similar.
###

# Basic core stuff
require "./common"
require "./taginfo"
require "./command-line"
require "./config/ephemeralconfig"
require "./config/theme"
require "./filemanager"

# Load all UIs
require "./uis/orig/orig"

# Playback and rendering
require "./audio-driver"
require "./playermanager"
require "./rendering/renderer"
require "./remotemanager"
require "./programmanager"

####
#### Main Toplevel Program
####

lib LibC
  # Local binding for dup().  We'll name it this to prevent a possible future
  # conflict.
  fun remidup = "dup"(fd : LibC::Int) : LibC::Int
end

module Benben
  RemiLib.classSetonce! ui : Benben::UI?
  RemiLib.classSetonce! fileHandler : Benben::FileManager?
  RemiLib.classSetonce! driver : Benben::AudioDriver?
  RemiLib.classSetonce! player : Benben::PlayerManager?
  RemiLib.classSetonce! manager : ProgramManager?
  RemiLib.classSetonce! logFile : File?
  RemiLib.classSetonce! remote : RemoteManager?

  # The loaded configuration file.
  @@config : EphemeralConfig = EphemeralConfig.new
  class_property config

  # The loaded UI theme.
  @@theme : Atomic(Theme?) = Atomic(Theme?).new(nil)
  #@@themeMut : Mutex = Mutex.new

  def self.theme : Theme
    #@@themeMut.synchronize { @@theme.not_nil! }
    @@theme.get.not_nil!
  end

  def self.theme=(newTheme : Theme) : Theme
    #@@themeMut.synchronize { @@theme = newTheme }
    @@theme.set(newTheme)
    newTheme
  end

  #class_property! theme

  # Used to find the configuration file and data files.
  @@resolver = Benben::Utils.makeResolver
  class_getter resolver

  # Holds the original screen attributes.
  @@originalAttrs : LibC::Termios?
  class_property originalAttrs

  # When non-nil, this is the file that Crystal's standard error is redirected to.
  class_getter! errorOutput : File?

  # When non-nil, this is the file descriptor that the real standard error is redirected to.
  class_getter! errorOutputReal : IO::FileDescriptor?

  # The original file descriptor for the Crystal version of stderr
  class_getter origError : LibC::Int = -1

  # The original file descriptor for the real stderr
  class_getter origErrorReal : LibC::Int = -1

  protected def self.errorOutput=(@@errorOutput : File?); end
  protected def self.errorOutputReal=(@@errorOutputReal : IO::FileDescriptor?); end
  protected def self.origError=(@@origError : LibC::Int); end
  protected def self.origErrorReal=(@@origErrorReal : LibC::Int); end

  at_exit do
    # Restore the original screen attributes on exit.  S-Lang sometimes doesn't
    # do this correctly.
    Benben.originalAttrs.try do |attrs|
      LibC.tcsetattr(STDOUT.fd, LibC::TCSANOW, pointerof(attrs))
    end
  end

  class Program
    def initialize
      LibC.tcgetattr(STDOUT.fd, out origAttrs)
      Benben.originalAttrs = origAttrs

      # This is meant for debugging, and is currently only useful if Benben is
      # built with -Dbenben_debug
      if filename = ENV["BENBEN_DEBUG_LOG_FILE"]?
        mode = "w"
        if filename.starts_with?("append:")
          mode = "a"
          filename = filename[7..].strip
        else
          filename = filename.strip
        end

        unless filename.empty?
          begin
            Benben.logFile = File.open(filename, mode)
            RemiLib.log.defaultStream = Benben.logFile
            RemiLib.log.debugStream = Benben.logFile
            Benben.logFile.sync = true
            RemiLib.log.dlog!("=== Benben Debug Log File, opened #{Time.local} ===")
          rescue err : Exception
            RemiLib.log.fatal("Couldn't open the requested log file: #{err}")
          end
        else
          RemiLib.log.fatal("Couldn't open the requested log file: no file specified")
        end
      end

      # Ensure we have a minimum number of worker threads.
      Benben.dlog!("CRYSTAL_WORKERS: #{ENV["CRYSTAL_WORKERS"]?}")
      if crWorkers = ENV["CRYSTAL_WORKERS"]?
        if crWorkers.to_i32 < 4
          RemiLib.log.fatal("Could not request at least four CRYSTAL_WORKERS from the Crystal runtime")
        end
      end

      # Initialize and parse the command line
      initCLI
      Benben.args.parse

      # Check for arguments that may exit the program early.  This will also
      # check their values.  Also, if we're rendering, make sure arguments for
      # that mode are good.
      checkEarlyOutArguments
      checkRenderingArgs
      checkForUselessRenderingArgs
      checkOtherArguments

      # We require at least one VGM file
      if Benben.args.positionalArgs.empty?
        RemiLib.log.error("No audio files specified")
        exit 1
      end

      # Create a Configuration Manager, which will load the configuration files
      conf = Config.load(Benben.resolver)
      Benben.config.apply(conf)
      Benben.config.apply(Benben.args)
      Benben.config.loadSongConfigs

      # Load the theme
      Theme.ensureDirectory(Benben.resolver)
      Benben.theme = Theme.load(Benben.resolver, Benben.config.uiConfig.theme)
    end

    # Initializes the file handler.
    private def initFileManager : Nil
      # Create the FileManager.  This will pre-check all requested files and
      # create the play queue.
      if (Benben.args["render"].called? && !Benben.args["quiet"].called?) || !Benben.args["render"].called?
        RemiLib.log.log("Checking files...")
      end
      STDOUT.flush
      Benben.fileHandler = FileManager.new
      if (Benben.args["render"].called? && !Benben.args["quiet"].called?) || !Benben.args["render"].called?
        RemiLib.log.log(formatStr("~:d file~:p queued for playback~%", Benben.fileHandler.size))
      end

      # Do we have files to play?
      if Benben.fileHandler.size == 0
        RemiLib.log.error("No files to play")
        exit 1
      end

      # --scan-only?  If so, exit now.
      if Benben.args["scan-only"].called?
        RemiLib.log.log("Exiting due to --scan-only")
        exit 0
      end
    end

    # Redirects standard error to a file named `stderr.log` in Benben's data
    # directory.
    def self.redirectStandardFiles : Nil
      {% begin %}
        unless Benben.config.stderrLogging.none?
          # We'll use a data file handled by RemiConf for this.
          Benben.resolver.defineDataFile(:stderrRedirect, "stderr.log")
          filename = Benben.resolver.dataFile(:stderrRedirect)

          # Open the file
          newErr = File.open(filename, "w+b")

          # Display a message
          if Benben.config.stderrLogging.full?
            RemiLib.log.log("Logging all further stderr messages to #{filename}")
          elsif Benben.config.stderrLogging.crystal_only?
            RemiLib.log.log("Logging further stderr messages (core only) to #{filename}")
          else
            raise "Unexpected value for stderr logging: #{Benben.config.stderrLogging}"
          end

          # Redirect Crystal's standard error to the file.
          Benben.origError = LibC.remidup(STDERR.fd) # Why is this fd 5?
          STDERR.reopen(newErr)

          # Possibly redirect the real stderr to the file.
          if Benben.config.stderrLogging.full?
            {% if compare_versions(Crystal::VERSION, "1.6.0") <= 0 %}
              realStderr = IO::FileDescriptor.new(2)
            {% else %}
              realStderr = IO::FileDescriptor.new(2, close_on_finalize: false)
            {% end %}
            Benben.origErrorReal = realStderr.fd
            Benben.errorOutputReal = realStderr
            realStderr.reopen(newErr)
          end

          # Keep track of the new file
          Benben.errorOutput = newErr
        end
      {% end %}
    end

    # Starts the rendering process.
    private def runRendering
      # Start the renderer
      Benben::Rendering::Renderer.new.render
    end

    # Starts normal (non-rendering) operation.
    private def runNormal
      # Initialize audio driver.  We do this before starting up the printer
      # because some drivers may print extra info that isn't related directly to
      # this program.
      drv = if Benben.args["driver"].called?
              Driver.parse(Benben.args["driver"].str)
            else
              Benben.config.audioDriver
            end

      # Initialize audio driver (i.e., the backend used for output)
      begin
        Benben.driver = AudioDriver.new(drv)
      rescue err : RemiAudio::Drivers::AudioDeviceError
        RemiLib.log.fatal("Could not initialize sound driver: #{err}")
      end

      # Create a ProgramManager
      Benben.manager = ProgramManager.new

      # Redirect messages
      #Benben::Program.redirectStandardFiles

      # Start the ProgramManager
      Benben.manager.run
    ensure
      RemiLib::Console.setTitle(STDOUT, "Benben")
      Benben.dlog!("Within main ensure, deinitializing stuff")

      # Clean up driver resources
      Benben.driver?.try &.deinit

      # Flush
      STDOUT.flush
      STDOUT << '\n'
    end

    def run
      # Start the file handler.  This handles its own printing.
      initFileManager

      if Benben.args["render"].called?
        runRendering
      else
        runNormal
      end
    end
  end
end

###
### Signal Handling
###
### Note: this must come before the entry point in this file.
###

Signal::WINCH.trap do
  # This could be nil if we're rendering
  Benben.ui?.try do |rawUI|
    if ui = rawUI.as?(Benben::OrigUI)
      Benben.dlog!("Refreshing screen size due to SIGWINCH")

      # Reinitialize S-Lang and then update the screen size
      ui.uiLock.synchronize do
        LibSLang.SLsmg_init_smg()
        RemiSlang::Screen.updateScreenSize
      end
      ui.queueRedrawWholeScreen
    end
  end
end

Signal::TSTP.trap do
  Benben.dlog!("Suspending")

  # Let the UI know we need to suspend
  Benben.ui?.try do |rawUI|
    if ui = rawUI.as?(Benben::OrigUI)
      ui.uiLock.synchronize do
        # The OrigUI needs to remember its current TTY state so it can properly
        # restore it later.  Without this, input handling goes wonky and starts
        # needing Enter to be pressed after each key input.
        LibC.tcgetattr(STDOUT.fd, out currentAttrs)
        ui.currentAttrs = currentAttrs
      end
    end
  end

  RemiSlang::Screen.clear
  RemiSlang::Screen.refresh

  # Send SIGSTOP
  Process.signal(Signal::STOP, Process.pid)
end

Signal::CONT.trap do
  Benben.dlog!("Unsuspending")

  # This could be nil if we're rendering
  Benben.ui?.try do |rawUI|
    if ui = rawUI.as?(Benben::OrigUI)
      ui.uiLock.synchronize do
        # The OrigUI needs to restore its TTY state from before the suspend so
        # it can properly restore it later.  Without this, input handling goes
        # wonky and starts needing Enter to be pressed after each key input.
        ui.currentAttrs.try do |attrs|
          LibC.tcsetattr(STDOUT.fd, Termios::AttributeSelection::TCSANOW, pointerof(attrs))
        end
      end

      # Redraw the screen
      ui.queueRedrawWholeScreen
    end
  end
end

###
### Entry point
###

begin
  RemiLib.log = RemiLib::ConcurrentLogger.new
  {% if flag?(:benben_debug) %}
    RemiLib.log.forceFlush = true
  {% end %}
  Benben::Program.new.run
rescue err : RemiLib::Args::ArgumentError
  RemiLib.log.fatal("#{err}")
rescue err : YAML::Error
  RemiLib.log.fatal(%|Config error: #{err}

Your config file is located here: #{Benben::Config.configPath(Benben.resolver)}|,
                    2)
#   RemiLib.log.fatal(%|Config error: #{err}

# NOTE: If you did not make any recent changes to your config file, then it may
# need updating.  Benben's config file format changed starting in v0.5.0 and
# cannot be auto-converted.  Either fix the error mentioned above, or
# delete/rename your old config and then run Benben again to recreate a fresh one.

# Your config file is located here: #{Benben::Config.configPath(Benben.resolver)}|,
#                     2)
rescue err : Yuno::YunoError
  {% if flag?(:benben_debug) %}
    RemiLib.log.fatal(err)
  {% else %}
    RemiLib.log.fatal("Internal YunoSynth error: #{err}", 128)
  {% end %}
rescue err : Benben::BenbenError
  Benben.ui.try do |ui|
    begin
      ui.deinit
    rescue Exception
    end
  end
  RemiSlang.deinit
  RemiLib.log.fatal("#{err}", 1)
ensure
  RemiLib.log.as(RemiLib::ConcurrentLogger).drain
  Benben.logFile?.try do |file|
    file.flush
    file.close
  end

  # Cleanup the stderr file, if it was used
  Benben.errorOutput?.try do |file|
    file.flush
    file.close
  end

  # Cleanup Crystal's stderr, if needed
  unless Benben.origError == -1
    STDERR.flush
    LibC.dup2(Benben.origError, STDERR.fd)
    LibC.close(Benben.origError)
  end

  # Cleanup the real stderr, if needed
  unless Benben.origErrorReal == -1
    LibC.fsync(Benben.origErrorReal)
  end
end

###
### KLUDGE: Totally janky main() override since we can't spawn first-class
### threads in Crystal yet.  We don't want to spawn too many worker threads
### because that just wastes system resources.  We can't spawn too few or
### performance degrades (or the program gets glitchy).  So we want to spawn a
### good amount.
###
### Ideally we could just have the option to spawn Threads ourselves instead of
### _only_ Fibers.  Then we wouldn't need to pre-process the command line,
### override an environment variable, override main(), bind atoll...
###
### https://portal.mozz.us/gemini/nanako.mooo.com/gemlog/2023-11-01-a.gmi
###

lib LibC
  fun atoll(str : UInt8*) : Int64
end

fun main(argc : Int32, argv : UInt8**) : Int32
  rendering : Bool = false
  jobsNext : Bool = false
  jobs : Int64 = -1
  minWorkers : Int32 = 9

  # Attempt to detect --render/-n and --jobs.
  argc.times do |idx|
    str = String.new(argv[idx])
    if str == "--render" || str == "-n"
      rendering = true
    elsif str == "--jobs"
      jobsNext = true
    elsif jobsNext
      jobs = LibC.atoll(str) # Will return 0 if it can't be converted
      jobsNext = false
    end
  end

  if rendering
    # We don't really care if jobs is 0 or negative or whatever.  We just want
    # to spawn a positive number of worker threads.  The actual command line
    # checking will handle invalid values later on.
    if jobs > 0
      # +2 so we don't starve ourselves of worker threads.  We always want at
      # least minWorkers, though.
      LibC.setenv("CRYSTAL_WORKERS", Math.max((jobs + 4), minWorkers).to_s, 1)
    else
      # This will need to be raised in the future if Benben gets more heavily
      # parallel.  We should ideally always have a few spare workers for the
      # extra fibers we spawn.
      LibC.setenv("CRYSTAL_WORKERS", Math.max(System.cpu_count + 2, minWorkers).to_s, 1)
    end
  else
    # This will need to be raised in the future if Benben gets more heavily
    # parallel.  We should ideally always have one spare worker.
    LibC.setenv("CRYSTAL_WORKERS", minWorkers.to_s, 1)
  end

  Crystal.main(argc, argv)
end