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
Releaseis an omnibaseThread(a JOINED subclass, tablemusic_release): title, headline, excerpt, liner notes, slug, publish states and translations come from the thread; the release adds its type, itsLabeland catalogue number, its UPC, its date, its sleeve, itsTracks, its performers, its prizes and its links per platform. - Repertoire, films, instrument, performers, playlists.
Work,Video(aThreadtoo),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.
Proposer une modification 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.
Proposer une modification 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), elsecoverUrl.getPlatformLinks(): Omnisong\Model\PlatformLinks- the links, with the label in front: the label's link islabelUrl, 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,labelUrlalone is the label link.getLabelLink()- the label's page of this record when there is one (labelUrlor the pattern), else the label's shop, else its site.getAwardList()- each line ofawardsas{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, ornullwhen 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;publishedAtset 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 |
Proposer une modification 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)- itsavailableFrom, 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"
Proposer une modification 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.
Consent
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\UserAgentClassifierwhen the core has it (anything buthuman), 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); })();
});
Proposer une modification 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 acoverUrlis set), the label (an existingLabelof 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 (andlabelUrl). - 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.
Proposer une modification 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,sdorhq, 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.
Proposer une modification 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).
Proposer une modification 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.
Proposer une modification 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 |
Proposer une modification 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).