#### 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