Het creëren van de Aetheryte Radio

  • De "loop"-functionaliteit van de YouTube-speler werkt niet altijd. Soms herhaalt de video 20 of 200 keer voordat hij faalt en automatisch een andere video start, wat erg storend is.
  • De audio in de video is een opname van ongeveer 7 minuten, maar de loop is niet naadloos. Hoewel de track lang genoeg is dat de overgang me niet direct stoort, zou een naadloze overgang (gapless playback) prettiger zijn.
  • Ik kan het spel niet constant open laten staan. Dat is veel te intensief voor de systeembronnen, en andere spelers (FFXIV is een MMORPG) kunnen in de buurt komen en geluid maken, wat het effect verpest.

Hoewel dit geen absolute dealbreakers waren, besloot ik na zes jaar eindelijk het probleem echt aan te pakken. Ik vroeg me af: "Hoe kan ik de beste ervaring voor de ambiance krijgen zonder concessies?" Het eerste antwoord was dat ik de game-assets nodig had. Ik moest extraheren wat het spel precies gebruikte om het geluid te produceren; met die bestanden in handen kon ik ze naar eigen wens recreëren of afspelen.

Het verkrijgen van de bronbestanden

FFXIV is niet super streng beveiligd. Via een webzoekopdracht naar "ffxiv data explorer" vond ik oude Java-applicaties die de asset-pakketten uit de installatiemap van het spel laden en je in staat stellen om (vermoedelijk gecomprimeerde) assets te exporteren. Ik herinner me niet meer welke fork ik precies gebruikte, maar het bevatte een hashtabel die de bestanden koppelde aan Engelse bestandsnamen.

Het klinkt misschien lachwekkend, maar ik heb vier uur lang 4000 audio-assets doorlopen om de juiste te vinden. Ik selecteerde letterlijk alle 4000 .wav-bestanden en gebruikte de pijltjestoetsen in een grote mpv-playlist om heen en weer te schakelen, zoekend naar het aetheryte-geluid. Het grappige is dat ik ze niet vond en uiteindelijk om 03:00 uur 's nachts naar bed moest.

Als engineer had ik echter slimmer moeten werken in plaats van harder. Er was een betere manier om aan het geluid te komen. FFXIV heeft een uitgebreide modding-community en een platform dat indrukwekkende mogelijkheden biedt voor het aanpassen van de ervaring.

xivlauncher, vfxeditor en soundfilter

Door FFXIV op te starten via xivlauncher, kun je plugins laden. Eén interessante plugin was soundfilter. Hoewel de naam enigszins misleidend is, heeft deze de mogelijkheid om de namen van de assets die op dat moment worden afgespeeld te tonen. Dit hielp enorm: ik kon naar een nieuw gebied in het spel teleporteren (aetherytes zijn in feite waypoint/teleport-kristallen) en precies achterhalen welk geluid er werd afgespeeld.

Kort daarna ontdekte ik dat het asset dat ik zocht was: bgcommon/sound/fst/placednpcethelightbig_loop.scd/0

Ik exporteerde dit bestand direct. Een interessant detail over dit asset is de /0 aan het einde. Ik ben er vrij zeker van dat het geluids- "asset" meerdere geluiden bevat (in het spel worden deze tracks genoemd). De 0 was simpelweg de "hum" of de lage frequentie van de ambiance.

De assets zijn als volgt onderverdeeld:

  • Hum
  • Whir 1
  • Whir 2

Het verkrijgen van de volledige ambiance vereiste echter meer inspanning. Ik had deze geluiden simpelweg in Logic Pro kunnen samenvoegen, maar dat zou te makkelijk zijn. Bovendien had ik dan een extreem lange track moeten maken om te voorkomen dat ik onbewust zou merken wanneer de loop zich herhaalt. Gelukkig had ik de juiste tools in handen om dit probleem op te lossen.

Web Audio API

Hier komt de Web Audio API in beeld. Audioverwerking convergeert vaak naar het idee van "nodes" die samples produceren, transformeren of consumeren. Dit is waarschijnlijk afgeleid van de status quo van vóór de grote investeringen in professionele AV-workflows op computers, wat waarschijnlijk modulaire synthesizers waren.

De hum

Ik heb niet veel analyse gedaan op dit specifieke asset. Ik nam aan (en had gelijk) dat de loop grotendeels perfect is. Een vriend hielp me om het sample-correct te krijgen, zodat het zonder een "klik" zou loopen bij gebruik van de Web Audio API (je kunt een "sink"-node instrueren om samples te blijven leveren door de offset te resetten). Er was verder geen post-processing nodig.

De audio node-graph is in dit stadium vrij eenvoudig: source node (hum.wav) -> gain node (-25db) -> output node

De gain node is aanwezig om de volumeregeling uit het spel na te bootsen. Het interessantere deel van de graph zijn de "whir"-nodes.

De whirs

FFXIV speelt de "whirs" af op willekeurige intervallen, toonhoogtes en volumes (gain). Deze worden allemaal toegevoegd om een "illusie van leven" te creëren, waardoor het moeilijker is om te detecteren wanneer een asset zich herhaalt. Gelukkig leverde FFXIV ook de minimale en maximale waarden voor elk van deze willekeurige parameters, wat eenvoudig te vertalen was naar JavaScript:

// select a random whir
const whirIndex = Math.floor(Math.random() * whirs.length);
// select a random pitch
const whirPlaybackRate = Math.random() * (1 - 0.794) + 0.794;
// select a random volume
whirGainNode.gain.value = Math.random() * (1 - 0.6) + 0.6;

Wanneer je handmatig een graph bouwt, is de flow meestal: een node alloceren, metadata instellen en verbinden. Voor de "hum" doe ik het volgende:

const humSource = audioContext.createBufferSource();
humSource.buffer = await loadSample(
  audioContext,
  isSafari ? "assets/hum.wav.opus.aac" : "assets/hum.wav.opus",
);
humSource.playbackRate.value = 0.63;
humSource.connect(humGainNode);
humSource.loop = true;
humSource.start(0);

Het is geen probleem dat ik de node verbind voordat ik loop instel, omdat de node pas samples produceert zodra ik start aanroep.

Het andere deel van het proces is de "whir"-loop. Dit is geen conventionele loop via control flow. In plaats van een while (true) met een willekeurige "sleep" ertussen, gebruik ik setTimeout om de volgende whir op een willekeurig interval te plannen.

function chooseWhir() {
  const whirSource = audioContext.createBufferSource();
  const whirIndex = Math.floor(Math.random() * whirs.length);
  const whirPlaybackRate = Math.random() * (1 - 0.794) + 0.794;
  whirGainNode.gain.value = Math.random() * (1 - 0.6) + 0.6;
  whirSource.buffer = whirs[whirIndex];
  whirSource.playbackRate.value = whirPlaybackRate;
  whirSource.connect(whirGainNode);
  whirSource.start(0);
  whirSource.onended = () => {
    // disconnect self and re-queue another whir
    whirSource.disconnect(whirGainNode);
    const nextWhirDelay = Math.floor(Math.random() * 2001);
    setTimeout(chooseWhir, nextWhirDelay);
  };
}

Het principe is hier hetzelfde: maak een source node, selecteer een willekeurig asset, een playback rate (pitch) en gain (volume), en verbind dit met de gain node (die constant blijft in dit proces en waarvan alleen de waarde wordt gewijzigd). De sleutel is dat wanneer het source-asset eindigt, we de node niet loopen, maar uit de graph verwijderen en een nieuwe toevoegen door chooseWhir opnieuw aan te roepen. Omdat er een verwachte vertraging is voor de volgende whir, is de latency die ontstaat door de willekeurige berekening marginaal en acceptabel.

Conclusie

Dit was een leuk project om aan te werken. Vanwege de huidige beperkingen van audio-afspeelbaarheid in browsers, moet je eerst met de pagina interacteren voordat je iets kunt horen. Zorg er dus voor dat je zowel de "whir" als de "hum" ontmuteert en op "connect" klikt om te kunnen luisteren.

Vanilla JS

Als engineer heb ik me vroeger niet zo beziggehouden met webtechnologieën; ik richtte me meer op systeemapplicaties, games en netwerken. Ik weet niet precies waar ik heb geleerd om webapps te bouwen, maar het was zeker niet de standaard npm + react + jsx/tsx routine van tegenwoordig. Ik bouw geen enterprise-grade webapps die bedoeld zijn voor samenwerking tussen 100+ developers; ik doe het alleen.

Ik vind het prima om statische elementen te maken en document.getElementById te gebruiken. Dit versimpelt het creatieproces aanzienlijk, waardoor ik sneller kan werken en me volledig op het project zelf kan concentreren. Geen frustraties met transpilation of JSX; mijn project was simpelweg niet zo gecompliceerd.