Audio

Charming can play sound files by shelling out to a system audio binary — no extra gem dependency. The playback engine lives in the Charming::Audio namespace; a small Charming::Components::Audio view renders a one-line playback indicator.

The Player

Charming::Audio::Player plays a single sound file by spawning a system audio binary as a detached child process and tracking it:

player = Charming::Audio::Player.new
player.play("assets/chime.wav")   # spawns the backend, returns the child PID
player.playing?                   # => true while the sound is still going
player.stop                       # terminates and reaps the child (safe if idle)

A backend binary is resolved on first use, in priority order:

Backend Platform Notes
ffplay any from ffmpeg; run with -nodisp -autoexit -loglevel quiet
afplay macOS  
paplay Linux PulseAudio/PipeWire
mpg123 Linux run with -q
aplay Linux ALSA, run with -q

If none are installed, play raises Charming::Audio::Player::Unavailable (a Charming::Error) with a message listing the backends it searched. Probe first with available? to degrade gracefully instead of rescuing:

player.play(path) if player.available?   # skip the chime when no backend exists

Player API

Method Behavior
play(path) Stops any sound in progress, spawns the resolved backend, returns the child PID. Raises Unavailable when no backend is installed.
stop Terminates and reaps the current child. No-op when nothing is playing.
playing? True while the spawned sound is still running.
wait Blocks until the current sound finishes, then clears it. Drive this from a background task, not the event loop.
available? True when a backend binary is installed for this platform.

Playing without blocking the event loop

The player holds a live child process, so it is not a view object — keep it in session rather than rebuilding it per render:

session[:audio] ||= Charming::Audio::Player.new

play returns immediately, so a fire-and-forget chime can go straight in an action. For a sound you want to track to completion — and tear down cleanly when the app quits — drive it from a controller run_task: spawn, wait, and reap in an ensure:

class JukeboxController < Charming::Controller
  key "p", :play_track
  on_task :audio, action: :track_finished

  def play_track
    player = (session[:audio] ||= Charming::Audio::Player.new)
    run_task(:audio) do
      player.play(track_path)
      player.wait        # blocks the task thread until the sound ends
    ensure
      player.stop        # no-op on normal finish; reaps the child if shutdown interrupts the task
    end
    render :show
  end

  def track_finished
    render :show   # the TaskEvent arrives when the sound finishes
  end
end

If the task thread is killed mid-wait (for example on app shutdown), the player keeps its PID so the ensure player.stop can still reap the child.

The Audio component

Charming::Components::Audio is an optional one-line status indicator. It reads a player’s playing? state and renders while playing or when stopped, with an optional label:

<%= render_component Charming::Components::Audio.new(
  player: audio,
  label: "chime.wav",
  theme: theme
) %>
# playing   => "▶ chime.wav"
# stopped   => "■ chime.wav"
# no label  => "▶"

The component only displays state — it never starts or stops playback. Pair it with a controller timer (or re-render on the on_task completion) so the glyph updates as the sound starts and finishes.

Testing

Player talks to the OS through an injectable adapter, Charming::Audio::System, which wraps Ruby’s Process/ENV/RbConfig. Inject a fake so specs never shell out, touch the real process table, or play sound:

class FakeAudioSystem
  attr_reader :spawned

  def initialize(available: [], macos: false, linux: false)
    @available, @macos, @linux = available, macos, linux
    @spawned = []
  end

  def macos? = @macos
  def linux? = @linux
  def which?(command) = @available.include?(command)
  def spawn(argv) = @spawned.push(argv) && 1234
  def terminate(_pid) = nil
  def alive?(_pid) = false
  def wait(_pid) = nil
end

system = FakeAudioSystem.new(available: %w[ffplay], macos: true)
player = Charming::Audio::Player.new(system: system)

player.play("song.wav")
expect(system.spawned.last).to eq(["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", "song.wav"])

For the component, stub the player’s playing?:

player = instance_double(Charming::Audio::Player, playing?: true)
expect(Charming::Components::Audio.new(player: player, label: "song.wav").render).to eq("▶ song.wav")

This site uses Just the Docs, a documentation theme for Jekyll.