Skip to content

omnibase/music

A musician's site on omnibase: discography with the label in front, repertoire, videos, instrument, performers, and a dependency-free player - the listening links through omnisense/omnisong.

Floor
Prepared structure · Omnibase
Version
main
Verified for real
Its README does not say
Licence
MIT
composer require omnibase/music:dev-main

A musician's site on omnisense/omnibase: the records and the label that released them, the repertoire, the films, the instrument, the people who play along - and one player for the whole page, with no JavaScript dependency. It is a Symfony bundle (Base\Music\MusicBundle) on the core 3 or 4, with its back office on omnibase/admin and its catalogues through omnisense/omnisong.

It was written first for two classical musicians - a harpist whose albums came out on ES-DUR, a violinist whose sonatas came out on FARAO classics - and keeps one rule of theirs: the label goes first. Its logo beside the sleeve, "released by", "buy it at the label" as the first button, and only then the streaming platforms (music.label_first).

What it adds:

  • Discography. A Release is an omnibase Thread (a JOINED subclass, table music_release): title, headline, excerpt, liner notes, slug, publish states and translations come from the thread; the release adds its type, its Label and catalogue number, its UPC, its date, its sleeve, its Tracks, its performers, its prizes and its links per platform.
  • Repertoire, films, instrument, performers, playlists. Work, Video (a Thread too), Instrument, Performer, Playlist, each with its page or its Twig function.
  • One player. One <audio> element for the page, a bar the layout includes once, static waveforms computed on the server with ffmpeg, "Listen in full" on Spotify, Apple Music or Deezer on a click, plays counted without keeping anything about the listener.
  • The catalogues. "Complete" and "Look up" in the back office fill a record from song.link and iTunes through omnisense/omnisong - only what is empty.

Use it for a performer's or a small label's site. It is not a streaming service: a track plays the site's own excerpt (or whole file, when its rights allow), a catalogue's 30-second preview, or a platform's player.

How this manual is organised

  • Installation: requirements, the bundles, the routes, the schema, the public files, what the layout provides.
  • Records, works and films: every entity, its fields and methods, the enums, the repositories.
  • The pages: every route, what each page shows, the JSON-LD, when a track can be heard.
  • The player: sources, the bar, "Listen in full", consent, plays, the hero film, the JavaScript API and events.
  • The catalogues: Omnisong, Complete, Look up, the label's links.
  • Waveforms and posters: music:peaks, music:posters, ffmpeg.
  • In a host page: the Twig functions and filter, the templates, the stylesheet's custom properties, the translations.
  • The back office: the screens, their actions, the dashboard tile.
  • Configuration reference: music:, the parameters, the settings, the services.
  • Testing: the suite and what it checks.

Propose a change docs/index.md

Installation

Requirements

What composer.json requires: PHP 8.2 or later, doctrine/orm 2.15 or 3, omnisense/omnibase 3.0 or 4.0, omnibridge/symfony 1.x, omnibase/admin, omnisense/omnisong 1.x, and symfony/config, dependency-injection, form, http-kernel, security-bundle, process and twig-bundle 6, 7 or 8.

On the server, ffmpeg computes the tracks' waveforms and reads the frames of the site's own films (apt install ffmpeg, apk add ffmpeg); without it, both are skipped and said so (Waveforms and posters). The poster picker needs PHP's GD extension.

The bundles

composer require omnibase/music:dev-main
// config/bundles.php
return [
    // ...
    Base\BaseBundle::class => ['all' => true],
    Base\Admin\AdminBundle::class => ['all' => true],
    Omnisong\Bridge\Symfony\OmnisongBundle::class => ['all' => true],
    Base\Music\MusicBundle::class => ['all' => true],
];

Omnisong's bundle registers Omnisong\Catalog\Catalog, Omnisong\Player\EmbedderInterface and Omnisong\Player\EmbedOptions; this bundle only asks them, each optionally. Without them, "Complete" and "Look up" say there is no catalogue, and no platform player is built.

MusicBundle extends the core's AbstractBaseBundle: getPath() is the package's root (templates/ is @Music, public/ is installed as bundles/music/), and build() aliases Base\Music\Entity\* to App\Entity\Music\* and Base\Music\Repository\* to App\Repository\Music\* unless the application declares a class of that name - an application declares App\Entity\Music\Release extends Base\Music\Entity\Release to add its own fields.

The routes

# config/routes.yaml
music_controller:
    resource: "@MusicBundle/src/Controller/Client"
    type: attribute
    prefix: /

This imports the pages (The pages) and music_hero_frame, the administrators' reframing of the home page's picture. The back office's routes come from omnibase/admin's base_admin loader.

Configuration

Every key is optional (Configuration reference):

# config/packages/music.yaml
music:
    artist: null
    player:
        waves: true
        live_waves: true
        order: [file, preview, embed]
        full: [spotify, apple_music, deezer]
        theme: light
    label_first: true
    country: DE
    jsonld: true
    repertoire:
        enabled: true
        group_by: formation
    instrument:
        enabled: true

The schema

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate

Tables: music_release and music_video (joined to the core's thread table), music_label, music_track, music_work, music_instrument, music_performer, music_playlist, and the join tables music_release_performer, music_video_performer.

The public files

bin/console assets:install

installs bundles/music/css/music.css (the pages' look), bundles/music/js/player.js (the player) and bundles/music/js/hero-frame.js (the reframing tool, for administrators).

What the layout provides

The pages extend layout1.html.twig and fill title, description, content, stylesheets and javascripts: the site's layout defines them.

The player's bar belongs to the layout: once, before </body>,

{{ music_player_bar() }}

so the music goes on from page to page of a Turbo site and plays from the home page. The bar loads player.js itself; the record, film and discography pages load it too.

Propose a change docs/installation.md

Records, works and films

A Release and a Video are omnibase threads; the others are plain rows. Uploads (#[Uploader]: a sleeve, a logo, an excerpt, a film, a poster, a picture) are stored by the core's uploader; their getters return the public path, and get…File() the file.

Release

Base\Music\Entity\Release extends Base\Entity\Thread (table music_release, discriminator music_release). From the thread: title, headline, excerpt, content (the liner notes), slug, state, publishedAt, the translations. Its own columns:

Property Column Default Meaning
type ReleaseType, string(16) ALBUM album, single, EP, live, compilation
label many-to-one Label, SET NULL null who released it
labelUrl string(255), nullable the record's own page at the label; wins over the label's pattern
catalogue string(64), nullable its number at the label
upc string(14), nullable digits only (setUpc() drops the rest)
releasedAt date, nullable
upcoming boolean false announced, not out
presaveUrl string(255), nullable the pre-save link while it is announced
cover upload, image, 16 MB the sleeve
coverUrl string(500), nullable a catalogue's sleeve, a remote address, until one is uploaded
links json, nullable platform value => URL (spotify, apple_music, qobuz...)
awards text, nullable the prizes, one a line
performers many-to-many Performer (music_release_performer) by position, then name
tracks one-to-many Track, cascade persist and remove, orphan removal by disc, then position
featured boolean false the record /music opens on

Methods:

  • getArtwork() - the sleeve's public address: the upload's path under /public (a path on disk is turned into its /uploads/... address), else coverUrl.
  • getPlatformLinks(): Omnisong\Model\PlatformLinks - the links, with the label in front: the label's link is labelUrl, else the label's pattern for this record (Label::releaseUrl()), else the label's site; then its shop; then the platforms by weight. Without a label, labelUrl alone is the label link.
  • getLabelLink() - the label's page of this record when there is one (labelUrl or the pattern), else the label's shop, else its site.
  • getAwardList() - each line of awards as {title, by}, split on the first dash between spaces (CD of the year — Radio România Muzical).
  • getOrderedTracks(), getDiscs() (tracks by disc), getPlays() (the tracks' plays together), getDuration() (the sum, or null when a track has no length).
  • addTrack() gives a track without a position the next one.
  • isOut() - not announced, and its date (if any) is today or past.
  • publish(bool $published = true, ?\DateTimeInterface $at = null) - published or draft; publishedAt set to $at, kept, or now.
  • setLink(Platform $platform, ?string $url); setLinks() drops empty addresses.

Track

music_track, indexed on isrc.

Property Column Default Meaning
release many-to-one, CASCADE
disc integer 1 at least 1
position integer, nullable its place on the disc
title string(255), nullable the title typed; else the work and the movement show
work many-to-one Work, SET NULL the work it is
movement string(255), nullable
duration integer, nullable seconds; 0 or less is null
isrc string(12), nullable upper-case alphanumeric, cut to 12
previewUrl string(500), nullable a catalogue's 30 s preview
sample upload, audio, 64 MB the site's own file: an excerpt, or the whole track
whole boolean false the file is the whole track
plays integer 0 counted by the player
peaks json, nullable the waveform: values between 0 and 1
performers text, nullable who plays on this track, as text
availableFrom date, nullable the day it can be heard (a single out before its album)
blockedIn json, nullable ISO country codes where it is not played

Methods: getDisplayTitle() (the title, else Work – movement), getDurationText() and Track::formatDuration($seconds) (4:07, 1:02:45), getSampleUrl() (the file's /uploads/... address), hasSample(), isWhole() (whole and a file), hasAudio() (a file or a preview), isOutOn(\DateTimeInterface $day) (availableFrom when set, else its release is out), getOutDate(), isBlockedIn(?string $country). A new sample (or none) forgets the peaks. setBlockedIn() keeps two-letter codes, upper-cased and sorted.

Label

music_label: name (not blank), slug (unique, from the name), logo (upload, 4 MB), url, shopUrl, releaseUrlPattern, country (ISO, upper-cased), description.

releaseUrl(Release $release): ?string fills the pattern (https://www.es-dur.de/releases/{catalogue}) with {catalogue}, {upc} or {slug}, URL-encoded; null without a pattern, or when the record lacks a value the pattern asks for.

Work

music_work, indexed on composer: composer and title (not blank), opus, year, instrumentation ("harp and orchestra"), formation (Formation, SOLO by default), duration, movements (one a line), period (Period, nullable), visible (true), position. getFullTitle() is title, opus; __toString() is composer: full title; getComposerSortKey() sorts by the last word of the name; getMovementList().

Video

Base\Music\Entity\Video extends Thread (music_video, discriminator music_video):

Property Column Meaning
file upload, video, 512 MB a film of the site's
youtubeId, vimeoId string(32) the id, read off any address given (watch?v=, youtu.be/, /embed/, /shorts/, /live/; vimeo.com/<id>)
poster upload, image, 16 MB
duration integer seconds
featured boolean
work, release many-to-one, SET NULL what was played, the record it belongs to
recordedAt, venue when and where
performers many-to-many (music_video_performer)

isLocal() (a file of the site's), getEmbedUrl(bool $autoplay = true) (https://www.youtube-nocookie.com/embed/<id>?rel=0&modestbranding=1, or https://player.vimeo.com/video/<id>?dnt=1, plus &autoplay=1; null for a local film), getWatchUrl(), isPlayable(), getPosterUrl(), publish().

Instrument, Performer, Playlist

Instrument (music_instrument): name, kind, maker, year, place, model, provenance (whose hands it went through), loan (who lends it), image (16 MB), description, visible, featured. getOrigin() is "Cremona, 1690".

Performer (music_performer): name, kind (PerformerKind), role ("piano"), url, position. __toString() is "Guillaume Vincent, piano".

Playlist (music_playlist): title, slug, url (not blank, a URL with a TLD), kind (PlaylistKind, OWN by default), description, featured, position, visible. getPlatform() reads the platform off the address (Omnisong's Platform::fromUrl(), and music.apple.com for an Apple Music playlist or artist); getLink() the PlatformLink.

Enums

Enum Cases Methods
ReleaseType ALBUM, SINGLE, EP, LIVE, COMPILATION schema() (schema.org's release type), fromCatalogue(?string) (ALBUM when unknown)
Formation SOLO, CHAMBER, CONCERTO, ORCHESTRA, VOCAL, OTHER rank(): solo, concerto, chamber, orchestra, vocal, other
Period BAROQUE, CLASSICAL, ROMANTIC, MODERN, CONTEMPORARY rank(), oldest first
PerformerKind PERSON, ENSEMBLE, ORCHESTRA, CONDUCTOR schema(): Person or MusicGroup
PlaylistKind OWN, RECOMMENDED, ARTIST rank(): artist, own, recommended

Repositories

Repository Methods
ReleaseRepository findPublished() (published, publishedAt past, newest first - announced ones included), findLatest(int $limit = 3, ?ReleaseType $type = null) (out only), findFeatured() (the featured one out, else the newest out), findOnePublished(string $slug), findUpcoming() (announced or dated in the future, the nearest first)
TrackRepository countSilent() (no file, no preview), countPlay(Track) (adds one in the database), countPlays(), findWithSample(bool $onlyWithoutPeaks = false)
WorkRepository findVisible(), findVisibleGrouped(string $by) and WorkRepository::group(): group => composer => works, by formation (by rank), composer (by surname) or period (oldest first, no period last)
VideoRepository findPublished(?int $limit = null) (featured first, then the newest), findFeatured(), findOnePublished(string $slug), findByRelease(Release)
InstrumentRepository findVisible() (featured first), findFeatured() (else the first visible)
PlaylistRepository findVisible(?PlaylistKind $kind = null) (featured first, then by position), findFeatured(?PlaylistKind $kind = null)
LabelRepository findOneByName(string $name), whatever its case
PerformerRepository the default queries

Propose a change docs/concepts.md

The pages

Base\Music\Controller\Client\MusicController and HeroFrameController:

Route Path Methods Sitemap Page
music_index /music any 0.9, weekly the discography
music_repertoire /repertoire any 0.6, monthly the works
music_videos /videos any 0.7, weekly the films
music_video /videos/{slug} any - one film
music_instrument /instrument any 0.5, yearly the instrument
music_release_full /music/{slug}/full GET, JSON - what the bar needs to play a record in full
music_track_play /music/play/{id} POST - one play, told by the player
music_release /music/{slug} any - one record (declared last, so it never takes another page's path)
music_hero_frame /admin/music/hero-frame POST - the home picture's framing, ROLE_ADMIN

Slugs are [a-z0-9-]+. A record or a film that is not published (or whose publication date is still to come) is a 404.

/music

@Music/client/index.html.twig with:

Variable Content
featured findFeatured(): the featured record that is out, else the newest out
upcoming the announced or future records, but the featured one
others every other published record, newest first
videos six films
artist the featured ARTIST playlist (the musician's profile), first under "Listen in full"
own, recommended the visible OWN and RECOMMENDED playlists

A record's page

/music/{slug} renders @Music/client/release.html.twig: the sleeve and the label (logo, "released by", the label's page and shop before the platforms, as music.label_first says), the player, the liner notes, the prizes, the performers, the films of the record (VideoRepository::findByRelease()), and - with music.jsonld - its JSON-LD.

JSON-LD

Base\Music\Service\JsonLd::album(Release $release, ?string $url = null, ?string $image = null) gives schema.org's MusicAlbum: name, url, image (the artwork), datePublished, albumReleaseType (ReleaseType::schema()), albumProductionType (StudioAlbum), catalogNumber, gtin (the UPC), description (excerpt, else headline), numTracks, byArtist (a MusicGroup named music.artist, else the site's title base.settings.title), contributor (each performer as a Person or MusicGroup), recordLabel, track (an ItemList of MusicRecordings: name, PT<m>M<s>S duration, isrcCode, recordingOf the work and its composer), and sameAs (every platform link). Empty values are left out.

/repertoire

The visible works, grouped (WorkRepository::findVisibleGrouped()) by ?by=formation, composer or period - another value falls back to music.repertoire.group_by. In each group, the works are by composer, sorted by surname. With music.repertoire.enabled: false the page is a 404.

/videos and a film's page

Every published film, featured first, then the newest. A film of the site's plays in place; a YouTube or Vimeo film shows its poster and a play button first, and loads youtube-nocookie.com or Vimeo with dnt=1 only on the click (and, with omnibase/consent, once MEDIA is accepted). A film's page shows four others.

/instrument

The visible instruments, featured first: the first at length, the others after it. With no visible instrument, or music.instrument.enabled: false, a 404.

/music/{slug}/full

JSON for the player's bar, public, cached 300 seconds, X-Robots-Tag: noindex:

Key Content
release the record's slug
title its title
page its absolute address on the site
platforms a list of {platform, label, html, src, height, page}
about the sheet's text about the record, as HTML

platforms is Player::listen(): for each platform of music.player.full the record has a link on and Omnisong can embed, the iframe and the record's page there (else the label's page of it, else the site's). about is @Music/client/_sheet_about.html.twig rendered in the reader's language.

/music/play/{id}

A POST from player.js once a track was heard (The player): 204, X-Robots-Tag: noindex. A track not on a published record is a 404; a user with ROLE_STAFF is answered without counting.

When a track can be heard

Base\Music\Service\Availability says whether a track plays here and now:

  • not before its day: Track::isOutOn(today) - its availableFrom, else its record being out. why() gives ['reason' => 'later', 'date' => ...];
  • not in a country of its blockedIn: ['reason' => 'region'].

The visitor's country (Availability::countryOf(Request)) is the country cookie, else the CF-IPCountry header, else X-Country (two letters; Cloudflare's XX and T1 ignored), else the region of the browser's first language (de-CH gives CH). None known: nothing is withheld for a country. music_source() gives no source for such a track and music_unavailable() says why (In a host page).

use Base\Music\Entity\Track;
use Base\Music\Service\Availability;
use Symfony\Component\HttpFoundation\Request;

$track = (new Track())->setIsrc('de-a12-25-00001')->setBlockedIn(['us', 'CA', 'xx1']);
echo $track->getIsrc(), ' | ', implode(',', $track->getBlockedIn()), "\n";

$request = Request::create('/music', server: ['HTTP_CF_IPCOUNTRY' => 'us', 'HTTP_ACCEPT_LANGUAGE' => 'de-CH,de;q=0.9']);
var_dump(Availability::countryOf($request), $track->isBlockedIn(Availability::countryOf($request)));
var_dump(Availability::countryOf(Request::create('/music', server: ['HTTP_ACCEPT_LANGUAGE' => 'de-CH,de;q=0.9'])));

Run in omnisense/omnibase's harness image (with omnisense/omnisong on the autoloader):

DEA122500001 | CA,US
string(2) "US"
bool(true)
string(2) "CH"

Propose a change docs/usage-pages.md

The player

One <audio> element for the whole page, driven by bundles/music/js/player.js, in the bar the layout includes once ({{ music_player_bar() }}). The pages' play buttons ([data-music-track]) hand their track to it.

What a track plays from

Base\Music\Service\Player::source(Track $track): ?array goes through music.player.order ([file, preview, embed] by default) and returns the first source found, {kind, src, whole}:

Kind Source
file the site's own file (Track::getSampleUrl()); whole is true when the track is ticked "Whole track"
preview the catalogue's 30-second preview (previewUrl)
embed the iframe of the record's first platform link Omnisong can embed, loaded on a click

null when there is nothing. Twig's music_source() also returns null for a track that cannot be heard now or here (The pages). Removing embed from music.player.order keeps platform players out of the track list (Player::embedsAllowed()); "Listen in full" stays.

The bar

It shows what plays, play and pause, the previous and next track of the same record, the progress on the waveform, the time and the volume; it can be tucked into a small handle and expanded into a sheet about the record. Space plays or pauses, the arrows seek five seconds, Shift+arrows change track. Its cross ([data-music-close]) stops the music and hides the bar; playing anything brings it back. Escape, with the focus in the bar, folds the sheet, then tucks the bar into its handle - the music goes on.

On <html>: music-is-playing while it sounds, music-playing-bar while the bar shows. The bar's data-music-waves is off, static or live (from music.player.waves and live_waves).

Waveforms

The waveform is static: the track's peaks (200 values, computed once on the server - Waveforms and posters) drawn on a <canvas>, the played part in the custom property --music-wave-played (currentColor by default), the rest in --music-wave (rgba(127,127,127,.45)). Without peaks, a plain line. With music.player.live_waves, the bar's canvas follows the sound through an AnalyserNode, unless the visitor asks for less motion. A preview from another site goes through Web Audio only when that site answers with CORS; otherwise it plays on a plain element, without the live wave.

Listen in full

A 30-second preview is a taste; a play on a platform by a listener signed in there is a stream, counted for the musician. Beside the previews, on a record's page and in the bar, "Listen in full on Spotify / Apple Music / Deezer" swaps in the platform's own player of the whole record. music.player.full says which platforms, in which order (Omnisong's Platform values); a record offers those it has a link on and Omnisong can embed (Player::full(), Player::hasFull()). Nothing loads from a platform before the click; the bar asks /music/{slug}/full for the iframes.

The platforms' players are built with Omnisong's EmbedOptions (its bundle's), on the theme music.player.theme.

The whole track on the site

A track plays in full from the site's own file when the file is uploaded on it (64 MB at most) and "Whole track" is ticked - unticked by default. The bar then offers no "Listen in full" for it.

Playing a recording in full on the site presumes the agreement of whoever holds its rights (the label, the publisher). An excerpt stays the default.

Spotify, Apple Music, Deezer, YouTube and Vimeo set their own cookies. With omnibase/consent on the page (window.Consent), a platform player - "Listen in full", the bar's sheet, a film from a platform - loads only once the visitor accepts the feature MEDIA: asked on the click that wants it, played as soon as they say yes, never on a hover. player.js declares the feature with the label and description of the translation keys music.consent.label ("Platforms' players") and music.consent.description. Without omnibase/consent, the players load on the click.

Plays

player.js posts /music/play/{id} once a track was heard - 30 seconds of it, or 80% of a shorter file, counting only the time that sounded: a jump on the waveform is not listening. Base\Music\Service\Plays::count() counts it unless:

  • the user agent is a robot: omnibase's Base\Service\Analytics\UserAgentClassifier when the core has it (anything but human), else an empty user agent;
  • the same visitor played the same track less than 20 seconds ago (Plays::EVERY), keyed by a hash of the address and the track in the cache pool, gone after those 20 seconds.

The controller does not count users with ROLE_STAFF. TrackRepository::countPlay() adds one in the database itself, so two listeners at once are two plays. Nothing about the listener is stored. What plays in a platform's own player is counted there.

The hero film

{{ music_hero_video() }} on the home page: a film muted, looping, playing by itself (autoplay muted loop playsinline), a button to hear it, its poster alone for a visitor who asks for less motion. The film and the poster are two settings, music.hero.video and music.hero.poster (an upload or an address); music_hero_video({poster: '...'}) gives the host's still until a poster is set. With neither, nothing is rendered.

For an administrator (ROLE_ADMIN) the film carries a "Reframe" tool (bundles/music/js/hero-frame.js): drag the picture, zoom from 1 to 2.5, save - a POST to /admin/music/hero-frame with the X-CSRF-Token header (token music-hero-frame) and {"position": "52% 31%", "zoom": 1.4} or {"reset": true}. Base\Music\Service\HeroFrame keeps them in the settings music.hero.position and music.hero.zoom (a zoom of 1 is null); a position that is not <x>% <y>% or a zoom outside 1 to 2.5 is a 422 with the message, a bad token a 403.

JavaScript API

player.js exposes window.MusicPlayer:

Member
audio the <audio> element
analyser the AnalyserNode, made on the first play a visitor asked for (even without live_waves); null before, or without Web Audio
context its AudioContext
current the current track {src, title, release, peaks}, or null
play(el | data) plays a [data-music-track] element, or {src, title, release, peaks}
pause(), toggle(), seek(seconds), next(), prev()
playRelease(slug) a record's first track, then pause and resume while one of its tracks is current
close() stops the music and hides the bar
tuck(), untuck(), expand(open?) the bar's handle and sheet
full(release, platform?), listenInFull(platform) "Listen in full" for a record (its slug), for the current record
redraw() draws the waveforms again after the page changed

Events, dispatched on document as CustomEvents:

Event detail
music:track the track that starts
music:play, music:pause, music:ended the current track
music:time {currentTime, duration}, about four times a second
music:full {release, platform}: a platform's player replaced the preview
music:film {title}: a film started
music:tuck, music:untuck the current track
music:close the track that was playing
document.addEventListener('music:play', () => {
    const { analyser } = window.MusicPlayer;
    if (!analyser) return;
    const data = new Uint8Array(analyser.frequencyBinCount);
    (function frame() { analyser.getByteFrequencyData(data); /* draw */ requestAnimationFrame(frame); })();
});

Propose a change docs/usage-player.md

The catalogues

The links to every platform, the previews and the platforms' players come from omnisense/omnisong: song.link (Odesli) for the links, the iTunes Search API for the tracks and their 30-second previews, and the iframes of Spotify, Apple Music, Deezer, YouTube and SoundCloud. Omnisong's bundle registers Omnisong\Catalog\Catalog, Omnisong\Player\EmbedderInterface and Omnisong\Player\EmbedOptions; this bundle asks them, each optional.

music.country (DE) is the store the catalogues are asked about and the one the links open.

Completing a record

Base\Music\Service\Importer::complete(Release $release, ?Reference $reference = null): ImportReport asks the catalogues what they know of a record, by $reference, else its UPC, else its first platform link (not the label's, shop's or "other" ones):

  • Only what is empty is filled: the title, the UPC, the date, the sleeve (coverUrl, a remote address, unless a sleeve is uploaded or a coverUrl is set), the label (an existing Label of that name whatever its case, or a new one with the catalogue's site and shop), the links of the platforms the record has none for.
  • The label's own links never come from a catalogue: they are the Label's (and labelUrl).
  • The tracks are matched by their ISRC, else by their disc and place (unless the track there is another recording, or was matched already); a matched track gets the title (when it has neither a title nor a work), the duration, the ISRC and the preview it lacks; an unmatched one is created.
  • When no catalogue knows the record itself, the links alone are filled.

Nothing is flushed. \InvalidArgumentException when the record has neither a UPC nor a link and no reference is given; \LogicException without Omnisong's bundle; what a catalogue throws beyond being down is an Omnisong\Exception\OmnisongException.

ImportReport: found, filled (the field names, links.<platform> for the links), tracksCreated, tracksUpdated, incomplete (the catalogues that did not answer: ask again later). isUnavailable() - nothing found because a catalogue was down; changedSomething().

Looking a record up

Importer::import(Reference $reference, ?ImportReport &$report = null): ?Release makes a record from one URL (any platform) or one UPC: the record of the site that already has the UPC, completed, or a new draft titled and typed as the catalogue says (ReleaseType::fromCatalogue()), persisted and not flushed. null when no catalogue knows it; $report says whether one was down.

Importer::reference(string $value) reads a loose string (a URL, a UPC or EAN, an ISRC) with Omnisong's Reference::parse(), for music.country.

use Base\Music\Service\Importer;

$release = $importer->import($importer->reference('https://open.spotify.com/album/...'), $report);
if (null !== $release) {
    $entityManager->flush();
}

In the back office

A record's Complete (/admin/releases/{entityId}/complete) and Look up a record (/admin/releases/lookup, a form with one field reference) run these and say what they did in flash messages: the fields filled and the tracks created or updated, "nothing new", "not found", or which catalogue did not answer. A catalogue that does not answer is not "not found": try again later.

The label first

Release::getPlatformLinks() puts the label in front - the record's page at the label (labelUrl, else the label's releaseUrlPattern filled with {catalogue}, {upc} or {slug}, else the label's site), then the label's shop - and the platforms after, by Omnisong's weight. music_links(release) draws them in that order; with music.label_first: false, the template gets label_first false.

Propose a change docs/usage-catalogues.md

Waveforms and posters

Both run ffmpeg on the server through symfony/process. Without it they compute nothing, say so, and the pages keep working: a plain line instead of a waveform, the poster as it is.

Waveforms

bin/console music:peaks              # the tracks with a file of the site's and no waveform yet
bin/console music:peaks --all        # every track with a file, again
bin/console music:peaks --track 12   # one track

or a record's Waveforms action in the back office (/admin/releases/{entityId}/peaks). -v lists each track (✓ computed, - skipped). Without ffmpeg the command warns and exits 0.

Base\Music\Service\Peaks::compute(Track $track, int $count = 200): bool sets a track's peaks (nothing is flushed): of its own file, else of its preview when the preview is an https:// address ffmpeg reads itself. ffmpeg decodes it to raw mono PCM at 8 kHz (ffmpeg -v error -nostdin -i <file> -ac 1 -ar 8000 -f s16le -, 300 s at most), and Peaks::reduce() cuts the samples into 200 equal windows, takes each window's RMS and scales them so the loudest is 1 (three decimals). Fewer samples than windows give one value a sample; silence gives zeros. isAvailable() asks ffmpeg -version once. A new file forgets the old waveform (Track::setSample()).

use Base\Music\Service\Peaks;

// A second of a 440 Hz tone that fades out, as ffmpeg gives it: mono, 16 bits little-endian, 8 kHz.
$pcm = '';
for ($i = 0; $i < 8000; ++$i) {
    $pcm .= pack('v', (int) (sin(2 * M_PI * 440 * $i / 8000) * 20000 * (1 - $i / 8000)) & 0xFFFF);
}
echo implode(' ', Peaks::reduce($pcm, 8)), "\n";
1 0.867 0.734 0.601 0.468 0.335 0.203 0.077

Posters

bin/console music:posters               # the films without a poster
bin/console music:posters --all         # every film, a poster set by hand included
bin/console music:posters --video 7     # one film

or a film's Pick from the film action (/admin/videos/{entityId}/poster). Base\Music\Service\PosterPicker::pick(Video $video): bool chooses among the film's own frames, with no machine learning:

  • a YouTube film: its frames at a quarter, a half and three quarters (maxres, sd or hq, the largest there is), and the thumbnail its channel chose, weighted 0.8 (its lettering reads as sharpness);
  • a Vimeo film: its thumbnail (oEmbed);
  • a film of the site's: six frames read by ffmpeg at 10, 25, 40, 55, 70 and 85% of its length (120 s when it has none).

Each picture has its letterbox bars cut off (uncropped()), then is measured on a copy 96 pixels wide (PosterPicker::score(), 0 to 1): exposure (best around 48% luminance, 0.25), contrast (0.20), sharpness by the Laplacian's mean (0.25), colourfulness (Hasler and Süsstrunk, 0.10), and the share of skin or warm wood and gilding - the musician and the instrument rather than an empty stage (0.20). A mostly black frame scores a quarter. The best becomes the poster, a JPEG file of the site (86% quality, 1280 pixels wide at most), so a visitor's page asks nothing of the platform before a click. Fetches time out after 8 seconds; an answer other than 200 is skipped.

Propose a change docs/usage-media.md

In a host page

Twig functions

Base\Music\Twig\MusicExtension, autoconfigured. The functions that render return safe HTML.

Function Returns
music_player_bar() the player's bar, once in the layout (it loads player.js)
music_player(release) "Listen in full" on the platforms, then the tracks with their play buttons and waveforms; a platform player when no track has audio and embed is allowed
music_player(release, {fold: true}) the same for a page showing several records: one "listen" button ([data-music-release-play]), the tracks behind a disclosure
music_player(track) one track's button
music_links(release) the label block, then the platforms as pills
music_full(release) "Listen in full on ..." alone
music_embed(release, platform = null), music_listen_embed(release, platform = null) one platform's player of the record (or the first that plays), behind a click; empty when none
music_playlist_embed(playlist) a playlist's player, behind a click; empty when its platform cannot be embedded
music_hero_video(attributes = {}) the home page's film (The player)
music_latest(limit = 3, type = null) the last records out; 'album' leaves the singles out
music_releases(), music_featured(), music_upcoming() the discography, the featured record, the announced ones
music_videos(limit = null) the published films
music_playlists(kind = null) the visible playlists, of a kind (own, recommended, artist)
music_instrument() the featured instrument
music_performers(release | video) "Guillaume Vincent, piano · Ensemble Sur Le Pont" - the template puts "with" before it
music_platform_icon(platform) Font Awesome classes: the brand's mark where Font Awesome Free has one (Spotify, Apple, YouTube, Deezer, Amazon, Bandcamp, SoundCloud, Napster), a bag for the shop, a disc for the label, Presto and HIGHRESAUDIO, a note otherwise
music_source(track) the track's source {kind, src, whole}, null when nothing plays or it cannot be heard now or here
music_unavailable(track) why it cannot be heard ({reason: 'later', date} or {reason: 'region'}), or null
music_has_full(release) whether "Listen in full" has a platform for it (asked once per record)

The filter music_duration writes seconds as a sleeve prints them: {{ 247|music_duration }} is 4:07, {{ 3765|music_duration }} 1:02:45, empty for null.

A home page:

{% set record = music_featured() %}
{% if record %}
    <h2>{{ record.title }}</h2>
    {{ music_links(record) }}
    {{ music_player(record, {fold: true}) }}
{% endif %}

{% for video in music_videos(3) %}
    <a href="{{ path('music_video', {slug: video.slug}) }}">{{ video.title }}</a>
{% endfor %}

Templates

@Music/client/: index, release, repertoire, videos, video, instrument (the pages), and the partials _player, _player_bar, _track, _tracks, _links, _label, _full, _embed, _hero_video, _release_card, _video_card, _upcoming, _sheet_about. @Music/admin/: widget/latest, release/lookup. An application overrides one in templates/bundles/MusicBundle/....

The stylesheet

bundles/music/css/music.css reads the host's custom properties: --music-accent, --music-bg, --music-fg, --music-soft, --music-line, --music-surface, --music-measure, --music-bar-bg, and for the waveforms (read by player.js) --music-wave and --music-wave-played.

Translations

The domains music (the pages, the player, the back office, the consent switch: consent.label, consent.description) and entities (the entities' names), ICU, in English, French and German (translations/*+intl-icu.{en,fr,de}.yaml).

Propose a change docs/usage-twig.md

The back office

Loaded when omnibase/admin is installed. The screens keep omnibase/admin's default permissions.

CRUD Slug Fields and actions
ReleaseCrudController releases the sleeve, title, state, slug, type, label, catalogue, UPC, label's page, date, plays (read), featured, announced and pre-save link, publication date, the catalogue's sleeve, performers, headline, excerpt, the tracks (a collection of TrackType), the links (LinksType: one address per platform, by rank, the label's and shop's left out), the prizes, the liner notes; filters state, label, featured, announced; Complete and Waveforms on a record, Look up a record on the list
TrackType (in a record) - disc, place, title or work and movement, length, ISRC, the file and "Whole track", the preview's address, performers, the day it can be heard, the countries it is not played in, plays (read)
LabelCrudController labels logo, name, slug, site, shop, record page pattern, country, description
WorkCrudController works composer, title, opus, formation, instrumentation, period, year, length, position, visible, movements
VideoCrudController videos poster, title, state, slug, publication date, YouTube or Vimeo address, the file, length, recorded on, venue, featured, work, record, performers, headline, excerpt; Pick from the film on a film
InstrumentCrudController instruments picture, name, kind, maker, place, year, model, lender, visible, featured, provenance, description
PerformerCrudController performers name, kind, role, address, position
PlaylistCrudController playlists title, slug, kind, address, position, visible, featured, description

A record or a film created in the back office has the signed-in user as an owner. The routes are omnibase/admin's admin_crud_<slug>_*; the actions' are admin_crud_releases_complete, admin_crud_releases_peaks, admin_crud_releases_lookup, admin_crud_videos_poster.

What Complete and Look up do is in The catalogues; Waveforms and the poster in Waveforms and posters.

The dashboard tile

Base\Music\Admin\Widget\LatestReleaseWidgetType, named music_latest (@Music/admin/widget/latest.html.twig): the last record out and the next one announced, each linking to its record; the plays of every track together; and how many tracks the player has nothing to play for (no file, no preview), highlighted when there are some.

// App\Controller\Admin\DashboardController::configureWidgetItems()
yield MenuItem::block('music_latest', 'Musique', 'fa-solid fa-record-vinyl');

Settings

The home page's film is set in the back office's settings: music.hero.video and music.hero.poster (an upload or an address); music.hero.position and music.hero.zoom are written by the reframing tool on the page.

Propose a change docs/back-office.md

Configuration reference

music:

Base\Music\DependencyInjection\MusicConfiguration, dumped with Symfony's YamlReferenceDumper in omnisense/omnibase's harness image:

music:
    player:

        # The static waveform of a track (its peaks) drawn behind the progress.
        waves:                true

        # The bar's wave moves with the sound (Web Audio); off, or for a visitor who asks for less motion, the static one stays.
        live_waves:           true

        # What a track plays from, first found first: the site's own file, a catalogue's 30 s preview, a platform's embed.
        order:

            # Defaults:
            - file
            - preview
            - embed

        # The platforms "Listen in full" offers for a release, in this order, when it has a link there (Omnisong\Platform values).
        full:

            # Defaults:
            - spotify
            - apple_music
            - deezer

        # The theme the platforms' embeds are asked for.
        theme:                light # One of "light"; "dark"

    # The musician's name, as the JSON-LD gives it (byArtist); null: the site's title (base.settings.title).
    artist:               null

    # The label - its logo, "released by", "buy it at the label" - before the streaming platforms.
    label_first:          true

    # ISO 3166-1 alpha-2: the store the catalogues are asked about, and the one the links open.
    country:              DE

    # A schema.org MusicAlbum on a release's page.
    jsonld:               true
    repertoire:

        # The /repertoire page.
        enabled:              true

        # How the works are grouped when the visitor has not chosen.
        group_by:             formation # One of "formation"; "composer"; "period"
    instrument:

        # The /instrument page (a 404 as long as no instrument is visible).
        enabled:              true
Option Type Default Read by Effect
player.waves bool true MusicExtension (Twig) draw the static waveform behind the progress
player.live_waves bool true MusicExtension the bar's wave follows the sound
player.order list of file, preview, embed all three Player what a track plays from, first found first
player.full list of Omnisong Platform values spotify, apple_music, deezer Player the platforms of "Listen in full", in order; an unknown value is skipped
player.theme light or dark light Player the platforms' players' theme
artist string or null null JsonLd byArtist; null reads base.settings.title
label_first bool true MusicExtension given to _links.html.twig
country string DE Importer the catalogues' store
jsonld bool true MusicController the MusicAlbum on a record's page
repertoire.enabled bool true MusicController /repertoire, or a 404
repertoire.group_by formation, composer, period formation MusicController the default grouping
instrument.enabled bool true MusicController /instrument, or a 404

Parameters

One container parameter per key and per level (the core's AbstractBaseExtension::setConfiguration()): music.player and each music.player.*, music.artist, music.label_first, music.country, music.jsonld, music.repertoire, music.repertoire.enabled, music.repertoire.group_by, music.instrument, music.instrument.enabled.

Settings

Kept in the core's setting bag, not in the configuration:

Setting Written by Read by
music.hero.video the back office's settings music_hero_video()
music.hero.poster the back office's settings music_hero_video()
music.hero.position HeroFrame (the reframing tool) music_hero_video(), kept only when it is <x>% <y>%
music.hero.zoom HeroFrame music_hero_video(), kept only from 1 to 2.5
base.settings.title the core JsonLd::artist() when music.artist is null

Services

config/services.php autowires and autoconfigures every class of src/ but DependencyInjection/, Entity/, Enum/, Controller/Admin/, Admin/, Service/ImportReport.php and the bundle class; Controller/Client/ is tagged controller.service_arguments. When the core has Base\Service\Analytics\UserAgentClassifier, Plays gets it as $classifier (nullOnInvalid). With omnibase/admin, Controller/Admin/ (tagged controller.service_arguments) and Admin/ (the widget type) are loaded.

Service Optional collaborators
Player Omnisong\Player\EmbedderInterface, Omnisong\Player\EmbedOptions, a logger
Importer Omnisong\Catalog\Catalog
Plays the core's user-agent classifier; the cache pool (CacheItemPoolInterface) is required
Peaks, PosterPicker a logger; PosterPicker an HTTP client; both take the ffmpeg binary as $ffmpeg (ffmpeg, a service argument to set another path)
JsonLd, MusicExtension the core's SettingBagInterface
Availability the RequestStack

Propose a change docs/configuration.md

Testing

The suite

vendor/bin/phpunit                                                    # checked out on its own
php vendor/bin/phpunit -c vendor/omnibase/music/phpunit.xml.dist      # from an application that requires it

tests/ runs without a kernel nor a database. tests/bootstrap.php registers this checkout's classes and those of the omnibase/* packages it requires or suggests, read beside it; it does not register omnisense/omnisong, which an application's autoloader has.

omnisense/omnibase's harness image does not install omnisense/omnisong: there, the suite needs Omnisong's checkouts on the autoloader. With the omnisong workspace mounted under /fam/omnisong and a prepended file that registers each composer.json's PSR-4 namespaces from /fam/*/*/:

<?php
// prepend-families.php
$loader = require '/srv/demo/vendor/autoload.php';
foreach (glob('/fam/*/*/composer.json') ?: [] as $file) {
    $c = json_decode((string) file_get_contents($file), true) ?: [];
    foreach ($c['autoload']['psr-4'] ?? [] as $ns => $path) {
        if (!isset($loader->getPrefixesPsr4()[$ns])) {
            $loader->addPsr4($ns, dirname($file).'/'.trim((string) $path, './'));
        }
    }
}
docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnibase/music:ro \
    -v ~/Sites/omnisong:/fam/omnisong:ro \
    -v /path/to/scratch:/scratch:ro \
    --entrypoint sh omnisense/omnibase-harness -c \
    'cd /srv/demo && APP_ENV=test php -d auto_prepend_file=/scratch/prepend-families.php vendor/bin/phpunit -c vendor/omnibase/music/phpunit.xml.dist --do-not-cache-result'
...............................................................   63 / 63 (100%)

OK (63 tests, 1428 assertions)

Without Omnisong, the 16 tests that reach Omnisong\Platform fail with "Class not found".

What it checks

File What
Entity/ReleaseTest the label first at the record's page its pattern gives; the record's own page at the label wins; without what the pattern asks, the label's site; the pattern takes the UPC and the slug; "buy it at the label" opens the record's page, else the shop; without a label the links are the links; prizes one a line; tracks by disc then place and their total; out and announced
Entity/ReleaseArtworkTest an uploaded sleeve is its address on the site; without one, the catalogue's
Entity/TrackTest durations; the title falls back to the work and its movement; ISRC normalised; peaks clamped and forgotten with a new file; "whole" needs a file; disc 1 at least
Entity/VideoTest the YouTube id from any address; embeds without tracking; a local film has no embed
Entity/PlaylistTest the platform read off the address; the link carries the title
Repository/WorkGroupTest by formation in their order and composers by surname; by composer; by period, oldest first, undated last; an unknown grouping is by formation
Form/LinksTypeTest one URL field per platform in their rank, the label left out; submitted into the array the entity stores
Service/AvailabilityTest an announced album plays its singles and keeps its other tracks for its day; a track of a record out plays unless its own day is ahead; a track withheld in a country is greyed there and only there
Service/PeaksTest the loudest window is 1 and the others follow their RMS; a sine gives an even wave of 200 bars; negative samples read as signed; short, silent and empty buffers
Service/PlayerSourceTest the site's file by its address, an excerpt by default; the whole track when ticked; without a file a catalogue's 30 s, never whole; no platform player, nothing to offer
Service/PlaysTest one listening is one play; a robot is not a listener; without the core's classifier only an empty user agent is a robot; the visitor is kept nowhere
Service/ImporterTest only empty fields are filled; a track matched by its ISRC first; a known label reused and a second run changes nothing
Service/PosterPickerTest a lit, sharp, warm frame wins over a black or flat one; a letterbox's bars are cut off
Twig/PlatformIconTest a brand's mark; a neutral icon for the others; every platform has one
Twig/PlayerContractTest every platform player waits for the visitor's yes; the consent switch is named in each language; the drawer has its three rests
Twig/WaveCanvasTest the templates give their canvas an id; the player's wave has one id either way
Controller/CrudRequiredFieldsTest the CRUDs' required fields, on omnibase/admin's CrudFieldsTestCase

In memory

The entities that are not threads, and the services' static parts, need no kernel. Run in the harness image with the omnisong checkouts on the autoloader:

use Base\Music\Entity\Track;
use Base\Music\Entity\Work;
use Base\Music\Enum\Formation;

echo Track::formatDuration(247), ' ', Track::formatDuration(3765), "\n";

$work = (new Work())->setComposer('Reinhold Glière')->setTitle('Harp Concerto in E-flat major')->setOpus('op. 74')->setFormation(Formation::CONCERTO);
echo $work, ' | ', $work->getComposerSortKey(), "\n";

$track = (new Track())->setWork($work)->setMovement('I. Allegro moderato');
echo $track->getDisplayTitle(), "\n";
4:07 1:02:45
Reinhold Glière: Harp Concerto in E-flat major, op. 74 | glière reinhold glière
Reinhold Glière: Harp Concerto in E-flat major, op. 74 – I. Allegro moderato

Release and Video are threads, whose constructor needs the kernel: the suite builds them with ReflectionClass::newInstanceWithoutConstructor() (and a subclass that keeps its texts in plain properties where translations are read, ImporterTest's UntranslatedRelease).

Propose a change docs/testing.md