Outro: Ruminate, Riff, Resolve

Outro is a Rails engine that provides your application with a chord library, general music theory tools, and tools for creating and displaying chord charts. Attach a transposable, instrument-aware chord chart to any model in your app with a single line.

Installation

Add this line to your application's Gemfile:

# Gemfile
gem 'outro_rails'

then bundle install.

Copy the engine's migrations into your app and run them:

bin/rails outro:install:migrations
bin/rails db:migrate

This creates four tables: outro_rails_chords, outro_rails_chord_voicings, outro_rails_chord_charts, and outro_rails_chord_chart_voicings.

Outro ships its chord library as seed data inside the gem. Load it either by adding this to your app's db/seeds.rb (so it runs with your normal bin/rails db:seed):

OutroRails::Engine.load_seed

or by running the provided task directly:

bin/rails outro_rails:seed

Seeding is idempotent, so it's safe to re-run at any time.

Usage

To view the chord library and music theory tools, mount the engine in your routes.rb:

Rails.application.routes.draw do
  mount OutroRails::Engine => "/outro"
end

If you use Devise and want the whole engine to require a signed-in user, wrap the mount in Devise's authenticate routing helper. Unauthenticated visitors will be redirected to sign in before any engine page renders:

Rails.application.routes.draw do
  authenticate :user do
    mount OutroRails::Engine => "/outro"
  end
end

Model: has_chord_chart

Attach a chord chart to any model, the same way ActionText attaches rich text:

class Song < ApplicationRecord
  has_chord_chart :chord_sheet
end

This gives the model chord_sheet/chord_sheet=/chord_sheet?, backed by a OutroRails::ChordChart row (record, name, plus artist, song (title), tuning, capo, key, and body). A model can attach more than one chart under different names.

Forms: chord_chart_area

In a form, pair it with the chord_chart_area form builder method:

<%= form_with model: @song do |form| %>
  <%= form.chord_chart_area :chord_sheet %>
  <%= form.submit %>
<% end %>

chord_chart_area renders artist/song/tuning/capo/key/body fields. Tuning options are pulled from Instruments::Guitar::Tunings, capo options are capped at fret 7 (Instruments::Guitar::Capo::FRETS), and key options are set from from Theory::Key.

Controller: Permitting the Parameters

chord_chart_area submits the chart as nested attributes, so your controller has to permit them alongside your own columns. The association is named chord_chart_<name>, where <name> is the argument you passed to has_chord_chart. A model declaring has_chord_chart :chord_sheet needs the key chord_chart_chord_sheet_attributes:

class SongsController < ApplicationController
  # ...

  private

  def song_params
    params.require(:song).permit(
      :description,
      chord_chart_chord_sheet_attributes: %i[artist body capo key song tuning]
    )
  end
end

The six permitted attributes are exactly the fields chord_chart_area renders. record and name are managed by the engine and should not be permitted. If a model attaches more than one chart, each one needs its own key: has_chord_chart :lead_sheet is permitted as chord_chart_lead_sheet_attributes.

With the parameters permitted, the chart saves through your existing update action.

Views: Displaying a Chart

To display it, embed the engine's chart partial in your own show view:

<%= render "outro_rails/chord_charts/chart",
      chord_chart: @song.chord_sheet,
      instrument: params[:instrument],   # "guitar" or "piano", default "guitar"
      to_key: params[:to_key],           # preview transposed into another key; view-only, nothing is saved
      editable: @song.editable? %>

This renders the chart's metadata, an instrument toggle, a transpose-to-key picker, the chart body with recognized chords highlighted, and a diagram for every chord the body references.

Authorizing Chart Edits

The chart's own content (artist, title, body, key, ...) is edited through your forms via chord_chart_area, so it's already protected by whatever protects the controller those forms submit to. The one write surface the engine itself owns is voicing selection.

Two things gate that:

  • editable: (the partial local) only decides whether the edit UI renders. It is not access control - the routes behind the buttons exist whether or not any page showed them.
  • OutroRails.chord_chart_voicing_authorization is the server-side check those routes enforce. It's a callable receiving chord_chart: and controller:, and it defaults to open (-> { true }) so the engine works with zero configuration.

If charts belong to users, set the lambda in an initializer. The controller argument is an engine controller, not one of your app's own, so helpers defined only on your ApplicationController aren't available on it:

# config/initializers/outro_rails.rb

# Or, with hand-rolled session auth:
OutroRails.chord_chart_voicing_authorization = ->(chord_chart:, controller:) {
  user = User.find_by(id: controller.session[:user_id])
  user && chord_chart.record.user_id == user.id
}

With Devise

Devise installs current_user, user_signed_in?, etc. on ActionController::Base itself, so they are available inside the engine's controllers. The lambda can use them directly. The usual rule, "whoever can edit the record can edit its chart", looks like:

# config/initializers/outro_rails.rb
OutroRails.chord_chart_voicing_authorization = ->(chord_chart:, controller:) {
  user = controller.current_user
  user.present? && chord_chart.record.user_id == user.id
}

Adjust the ownership clause to your schema (record.user == user, a membership check, an admin? bypass, ...). If your Devise scope isn't :user, call its helper instead (controller.current_admin, ...). If you run Pundit or CanCanCan on top of Devise, delegate to it so the rule lives in one place:

OutroRails.chord_chart_voicing_authorization = ->(chord_chart:, controller:) {
  user = controller.current_user
  user.present? && Pundit.policy(user, chord_chart.record).update?
}

Then have your view ask the same question when deciding whether to render the edit UI, so the buttons never appear for someone the server would refuse:

<%= render "outro_rails/chord_charts/chart",
      chord_chart: @song.chord_sheet,
      editable: OutroRails.chord_chart_voicing_authorized?(
        chord_chart: @song.chord_sheet, controller: controller
      ) %>

Development

Clone the repository and copy the .env.tmpl file to .env:

git clone git@gitlab.com:endtoendpaper/outro_rails.git
cd outro_rails
cp .env.tmpl .env

Configure the variables in .env

  • Leave the POSTGRES_USER and POSTGRES_PASSWORD as the PostgreSQL image defaults.
  • Set UID and GID to your local user's values so files created inside the container have the correct permissions. (id -u, id -g)

Example .env:

POSTGRES_USER=postgres
POSTGRES_PASSWORD=password
UID=1000
GID=1000

Build the docker containers and start the application:

docker compose build
docker compose up -d
docker compose exec gem bundle exec rails app:db:prepare

Access the dummy app from this link: http://localhost:3002

It seeds the chord library and four example songs (spec/dummy/db/seeds.rb). The example songs are listed on the home page. To illustrate permissions, the dummy app sets even record ids as editable and odd ones as view-only.

Publishing a New Version

1. Bump the version. Edit lib/outro_rails/version.rb

2. Update the changelog. Edit CHANGELOG.md

3. Commit and push

git commit -m "Release v0.1.0"
git push origin main

4. Normalize file permissions. Files that aren't world-readable get packaged that way and will break installs.

chmod -R a+rX app config db lib

5. Build the gem.

gem build outro_rails.gemspec

6. Verify package contents.

gem spec outro_rails-0.1.0.gem files

7. Push it.

gem push outro_rails-0.1.0.gem

License

The gem is available as open source under the terms of the MIT License.