- Leverage a multi-layer generation script to create, merge, and store music, icons, text, and shapes as NFTs.
- Use Soundraw to generate unique audio clips, then merge them with dynamic images and pinned metadata via Chainstack IPFS.
- Finally, mint your NFTs by loading metadata from IPFS, estimating gas, and signing transactions to deploy them on-chain.
- This approach showcases a full end-to-end pipeline for producing generative music NFTs, from creation to minting.
Main article
This tutorial builds upon the foundations outlined in the How to mint a music NFT: Dropping fire tunes with Chainstack IPFS Storage and the How to create generative NFTs: Making abstract art with code tutorials. It is strongly recommended that you have completed the tutorials before you proceed, or at the very least have the music NFT tutorial repo cloned, so you can use the code base as a starting point.Step 1: Process dependencies and initialize parameters
Much like the other scripts from the How to mint a music NFT: Dropping fire tunes with Chainstack IPFS Storage tutorial, you will need Hardhat with the web3.js plugin installed, Axios to submit HTTP requests, as well as Dotenv to store and access your DApp secrets.- Random words — generate a set of random words based on the desired number of words.
- Text-to-image — render a given string to an image and export it as an image file.
- Jdenticon — generate a random icon based on size and seed, then export it as an image file.
- Canvas — generate 2D graphics via the HTML
<canvas>
element. - Image data URI — decode and encode data URI images.
- Merge images — merge several images into a single image as its layers.
Make sure to install the
1.3.0
version of the random-words
library, as 2.0.0
introduces an array of changes that make it less accessible in a node.js workflow.generate.js
file in your ./scripts
directory, and process all dependencies like so:
Setting up your environment
You will also need access to the Soundraw API, in order to make audio file generations. Soundraw is an AI-powered platform that allows you to create unique, royalty-free music tailored to your needs. You can generate unlimited songs by simply selecting the mood, genre, and length of the track, and the AI will create the music. The platform enables you to customize the audio files you generate to fit specific requirements, for instance, adjusting the length of an intro or the position of a chorus. You can use the generated music freely without worrying about copyright strikes, making it perfect for YouTube videos, social media, commercials, podcasts, games, apps, and even NFTs. Once you have access to the Soundraw API and an API token, hop on to your.env
file and add it to the list like so:
generate.js
file and load the required environment variables after the dependency list:
Initializing generation parameters
Next, create a list of variables that will cover all generation parameters. You will need such for storing a random hex, the generation id, the combined random hex output with your wallet address, converted to number string, the digital root of the number string, the generated random word output, the font and background colors hex values, the generated shape parameters, in terms of number of sides, size, center X and Y positions, stroke, and fill hex color values. Here’s how:Muted
energy level, as it creates an empty audio segment, which won’t be necessary for the use case, as well as a sample length of 77 seconds and a default file format of mp3
.
generate.js
file in the /scripts/
directory should look like this:
Step 2: Set up the generative process
Once you’ve prepared the dependencies and set up the generation parameters, the next step is to create a seed. A seed is typically a random set of characters in a string that is used to feed various generation algorithms or libraries, but it can technically take any form. You can create a seed to kickstart your generative process by using therandomHex()
method in the utils
section of web3.js.
So, go ahead and create a new generator
asynchronous function as a constant and set the randomHex
variable’s value to the randomHex()
method with 20
as its first and only parameter. This will generate a random hex string with 20 bytes in length, starting with 0x
But this 0x
is hardly needed, so at the end of the method call add .concat(address.slice(2))
to trim the excess:
randomHex
seed value you just generated, then using the first and last three characters of the hex string to form the IDs:
hexToNumberString()
method like so:
categories
array, which is simply a collection of all Soundraw parameter categories, namely moods
, genres
, themes
, and tempo
. The same applies to categoryNames
. Then, there is also the numberOfTimeframes
, which indicates the number of segments you want to have in a generated audio for which you can set individual energy_levels
. Last, initialize as object a requestPayload
variable, which you will use to store the contents of your Soundraw API requests.
Creating the audio generation layer
Once ready, proceed by creating the loop that will be running four consecutive times. Inside it creates a new temporary arrayrandomIndices
and initializes it as empty. Then, creates another for
loop that will iterate three times.
Within it, set the randomIndices[index]
value to the integer (parseInt()
) sum of 0
and a random character (number) from the wordNrs
string using the charAt()
method. As parameter use Math.floor()
to round the output down to the nearest whole number, since array indices are integers. When it comes toMath.floor()
, add the Math.random()
method as a parameter, which generates a random floating-point number between 0 and 1 and multiplies this by the length of the wordNrs
string.
Next, sum the values of the randomIndices
array with reduce((a, b) => a + b, 0)
and check if the resulting randomIndex
value is greater or equal to the length of the given category, or less than 0
. If it is, rerun the loop calculations once more with a fresh start from 0
to match the lowest possible values and check again, resulting in randomIndex
being set to 0
in case of discrepancy again.
This will give you a valid index, based on the wordNrs
seed that can match the entire possible range of each of the categories
, in turn picking a value for it.
Apply this random index to a new temporary categorySelected
variable by setting its value to categories[i][randomIndex]
. After that, create an if-else
statement, where if categoryNames[i]
is different from 'Tempo'
the requestPayload[categoryNames[i]
value converted toLowerCase()
is equal to the categorySelected
variable you set just now.
Then, for the else
segment of your statement, set the value of the requestPayload.tempo
once again to categorySelected
. By doing this, you will select a value for each of the parameter categories
from its valid array values, while minding the formatting of each without breaking anything.
energyLevelsArray
array to store the objects that define the timestamps times for each energy level and another one called lengths
which will do so for the length of each timeframe.
Then, create a for
loop that will run as many times as the numberOfTimeframes - 1
and inside it use the Math.random()
method to generate a relative length of time, based on the total length for each energy level. You only need to create a number of divisions that is one less than the number of timeframes, as this will split the audio into segments equal to the number of timeframes.
Once you’re ready with the loop, sort the lengths
array in ascending order with the sort()
method to make sure each energy level starts after the previous one ends.
previous
and set its value to 0. After that, start a new for
loop. This time, the loop will adjust the random lengths
you generated earlier to make sure they add up to the total length of the audio. You will use the previous
variable to keep track of the cumulative sum of the lengths
processed in the previous loop.
Run the loop as many times as the numberOfTimeframes
and in each iteration adjust the j
th element of lengths
array by multiplying it with the total length
of the generated audio, as well as subtracting previous
from it. By doing this, you will get a length time segment that takes into account the total length of your audio and the sum of those before it.
Then, update the previous
to be the j
th element of lengths
as the newly computed length for this iteration. In doing so, you will subtract it from the next length in the following iteration of the loop, so they add up accordingly. Last, round the j
th element of the lengths
array to a single decimal place as a floating-point number to match the Soundraw formatting requirements.
currentTime
and initialize it once again as 0
to keep track of the current timestamp in the audio. Apart from that, create one final for
loop, which will also run as many times as the numberOfTimeframes
.
Inside the loop create a temporary variable energyStart
and store the starting timestamp for each timeframe, by setting it to the currentTime
as a floating point rounded to one decimal place. Next, do the same for energyEnd
, while setting to the sum of the currentTime
and the length of the timeframe, once again rounded to one decimal place if this is not the last timeframe. If it is, set it to the total length of the piece with j < numberOfTimeframes - 1 ? parseFloat((currentTime + lengths[j]).toFixed(1)) : length;
.
After that, go ahead and update the currentTime
to be the end of the current energy level, so you can start the next with the following iteration of the loop. Then, pick a random energy level from the energyLevels
array by using the same logic you applied to randomIndex
previously, just skip the extra triple loop you used there. Finish the loop off by pushing the start
, end
, and energy
values accordingly.
requestPayload
with the new values and print the selected parameters in the console, making them also a part of the generated audio’s filename. You will then use this payload to submit an HTTP request to the Soundraw API to kickstart the audio generation on the server side and get the file in return.
POST
request to the Soundraw API using axios
. To do this, you must first prepare the request, by targeting the compose
endpoint of the API as the url
and requestPayload
as the data
. Make sure you’ve set the Content-Type
header to application/json
and the Authorization
one to your Soundraw API key or soundraw
in this case.
then
method to specify what to do when the HTTP request is successful. In this case, the code first logs the response data, then uses the ‘path’ library to create a path for storing the audio file. The audio file is stored in a directory named audio
. This directory is relative to the src
directory, which resides in the same directory as the current script.
fs.createWriteStream
and download the MP3 file from the URL provided in the response. Pipe the response to the file stream, effectively downloading the file.
Exporting files to directories that do not exist yet may cause errors because of FS/OS permissions. Try creating them manually if you encounter such issue.
updateLocalMetadata
. If an error occurs during this process, log it to the console.
request
and file
objects are event emitters, emitting error
events when something goes wrong. Listen for these events and, when they happen, log the error to the console and close the file stream. In case the axios request throws any error, catch it and log it to the console using the catch
method.
axios
set up included:
Setting up the image generation layers
Once you’re ready setting up the audio generation layer, it’s time to move forward with the image one. To do that, you will first need to start doing some calculations for the shape layer. First, set the number of sides for the polygon shape. Extract a random character from thewordNrs
string, convert it to an integer, and add 1. Note that Math.random()
generates a random float from 0 to 1, and multiplying it by the length of the string helps in picking a random index from the string. Math.floor()
ensures that the value is a whole number, to use as an index.
Next, generate the stroke and fill colors for the shape. Combine different parts of the colorHex
and bgColorHex
to ensure a wide variation. Use the slice
function to extract parts of the strings.
wordNrs
and add it to the current property value. Multiply the result by Pi and add or subtract a constant. Use Math.abs()
to avoid negative results.
digitalRoot
function to reduce wordNrs
to a single digit. This function works by converting input
to a string and summing all its digits. If the result is a single digit, return it, otherwise, call the function recursively.
digitalRoot()
function with the wordNrs
as its only parameter to print the digital root result. Then, go ahead and check if the result
is odd or even by returning the remainder of that number divided by two. If the number is even, the function will return 0
, because the remainder of an even number divided by two is always zero, and if it is odd, it will return 1
.
randomStr
variable you defined earlier to the randomWords()
generator with the boolean
result of the odd/even check function as its solo parameter. Then, add 2
to it the result, as this is the easiest way to generate 2
words for even and 3
words for odd (even 0 + 2 base = 2; odd 1 + 2 base = 3).
Don’t forget to split the randomStr
according to ' ,'
as you will be left with an unformatted comma-separated word list instead. Follow it up by creating a for
loop that will capitalize the word set and join them as a single string to obtain the wordsOut
variable’s value.
wordsOut
word list, using textToImage
’s generate()
method, paired with the idHex
string as its first parameter. As its second, create an object, adding a wide range of options, including debug: true
as this is the only way you can render it to a file. Make sure you’re also creating a new temporary textPath
variable to store the file name and location information.
You can play around as much as you like with the other options here, just don’t forget to set the values for the bgColor
and textColor
keys to the bgColorHex
and colorHex
variables you defined and calculated earlier. For this tutorial, use maxWidth
set to 330
, textAlign
to center
, and verticalAlign
to top
as this will put the text at the top of the image centered, creating a bar-like element which is a typical place for placing such information.
iconSize
and iconSeed
. The seed is set to wordsOut
, meaning that the generated icon will be uniquely based on the random words string. After setting these parameters, use the jdenticon.toPng()
function to generate the icon and assign it to iconExport
.
So, go ahead and set the iconSize
to 350
and iconSeed
to wordsOut
, so you can use the two to three random words output as the basis for the generation. Then, call the toPng()
method of the jdenticon
package with those two parameters.
Remember to set the iconPath
with the location you want it exported to and the appropriate file name convention. Lastly, write the generated file to the location you have selected by calling the fs.writeFileSync
method.
shapeCanvas
constant, setting its value to a new Canvas()
with 350, 350
as its parameter. Then, create another one for shapeContext
and place shapeCanvas.getContext('2d');
as its value. This will allow you to start drawing a path on the canvas, meaning you can call the beginPath()
method from the shapeContext
.
shapeContext
path to four randomly generated points, in order to draw a path. And considering the example formula used to pick these points for the tutorial was defined without too much in-depth evaluation prior, feel free to play around with it to achieve more interesting points for the polygon shape.
Just do remember to connect the path points, based on the number of sides you picked for the shape earlier, using a for
loop, iterating as many times as the number of sides. To do that, use the lineTo()
method of the shapeContext
with the sum of the shapeCtrX
value and the shapeSize
one, multiplied by the cosine of the loop index
multiplied by 2
and Pi
, divided by the sides.
shapeStroke
and shapeFill
values you defined earlier as the strokeStyle
and fillStyle
. Last, record the shape’s dataURI
to an image buffer, so you can export it to an image file. Make sure you’re also setting the shapePath
with the appropriate location and file name, before you write it to file using the fs.writeFileSync()
method.
Merge layers and record local metadata to JSON
With all three parts of the image layer ready, you can finalize its generation by merging the text, icon, and shape renders into one. To accomplish this, you can use themergeImages
library with the shapePath
, iconPath
, and textPath
you defined earlier as parameters in this order. Don’t forget to create a mergePath
variable with the appropriate location and file name for the output.
Then, add an additional object parameter to mergeImages
, where the Canvas
and Image
keys have the same values. Finish off the merger, by including a .then
function with the response
as its parameter, where you use the ImageDataURI.outputFile()
method with the response
parameter once again, as well as the mergePath
variable you just defined.
updateLocalMetadata()
function, which you referenced during the audio layer generation process. Its aim is to write to JSON the metadata information you will then use to pin your media files to Chainstack IPFS and set your NFT metadata with.
The updateLocalMetadata()
function takes a long list of parameters, namely idHex
, coverPath
, audioPath
, wordsOut
, colorHex
, digiRoot
, requestPayload
, and length
. As its first order of business, create a new filePath
constant, where you set the appropriate location and file name for the JSON file you will be writing.
Then, inside a try
block, read the filePath
and check if the file exists. If it does, you will be parsing its contents as an object, so you can update it accordingly. But if it doesn’t you get to write everything directly. Set the idHex
value as the key of the object property and its value to another object with name
, description
, cover
, and audio
as its keys.
For the name
key, go ahead and use the idHex
and wordsOut
values, so you get a title like idHex: wordsOut
, ultimately resulting in something like 133337: Test Value West
. In turn, asdescription
, you can use something along the lines of A generative music NFT created with metadata seeds. Words: wordsOut, Color: colorHex, Digital Root: digiRoot, Mood: mood, Genre: genre, Theme: theme, Tempo: tempo, Length: length
.
By doing this, you will be able to fully highlight as many generation parameters as possible to paint a good picture of your NFT’s generative process and define its possible traits. Lastly, for cover
, as well as audio
just apply the correct path for each. Once you’ve set up the object, stringify it and write it back to the file located at filePath
.
generate.js
script. Let’s recap with the entirety of the script to avoid something going AWOL somewhere along the way:
Step 3: Pinning to Chainstack IPFS and minting
First things first, create a newpin.js
script inside your scripts/
directory, if you don’t have one already. If you do, you will be mostly updating your script throughout this step but if you don’t, rest easy for you will find detailed explanations of the process here too. So, go ahead and start by processing the necessary dependencies.
generateContent
, to read and parse thelocal-metadata.json
you generated in the previous script to a JSON object that contains the metadata of each media file to be pinned. Now, iterate over the json
object.
For each property in json
, create an element
object to store its value, as well as a new content
array to hold the media file details. Create a set of four new constants coverTitle
, audioTitle
, tokenTitle
, and tokenDescr
to set the titles for the image and audio files, as well as to retrieve the name and description of the NFT from the stored element
values.
Push the image and audio files to the content
array as streamable files with their respective titles. Next, push each content
array set, along with the tokenTitle
and tokenDescr
to the allContent
array. Then, return the allContent
array, containing all the details of the media files waiting to be pinned.
allContent
, move on to create another new function—addFiles
, which you will be using to pin files using the Chainstack IPFS Storage API. This function loops through each file in the source
array, sending an HTTP POST request to the Chainstack IPFS pinning service.
The source
parameter is the array of media files to pin, and the single
parameter determines the endpoint to which the request is sent. If single
is true
, the request is sent to the endpoint for pinning single files, otherwise, it’s sent to the endpoint for pinning multiple files.
The function uses a while
loop to attempt the pinning process multiple times if it fails. On each attempt, it logs the attempt number, error messages, and ID of the successfully pinned file.
FormData
instance.
Execute the HTTP request using axios
and store the response in the response
variable. If the pinning is successful, get the public ID of the pinned file from response.data.id
or response.data[0].id
, depending on whether a single or multiple files were pinned. Add the ID to the pubIDs
array.
If the pinning process fails, catch the error and retry the pinning process after waiting for the timeout period. If the process still fails after the maximum number of retries, throw an error. Return the pubIDs
array, which contains the IDs of the successfully pinned files.
findCIDs
function to retrieve the CID for each pinned file, which accepts two parameters: fileID
and single
flag, indicating if the fileID
provided is single or not. If the single
flag is true, remove any double quotes from the fileID
and make sure it is an array. If it’s not, convert it into one.
Next, define constants maxRetries
and retryTimeout
to control how many times the function should attempt to find the CID before giving up, and how long it should wait between each attempt.
If the single
flag is false, make the function create two empty arrays, cid
and name
, and then run a loop over the fileID
array. For each fileID
, recursively call the findCIDs
function with the single
flag set to true, pushing the results into the cid
and name
arrays respectively.
On the other hand, if the single
flag is true, initiate a loop controlled by the retries
counter and maxRetries
constant. In each iteration, send a GET
request to the Chainstack IPFS API and log the response. If a valid CID and filename are found, break out of the loop and return them.
In case of any error during the HTTP request, log the error message, increment the retries
counter, and wait for the retryTimeout
duration before continuing the loop. Finally, return an array containing the found CIDs and filenames when the single
flag is set to false, and a single pair of CID and filename when the single
flag is set to true.
writeJSON
function, which will handle the creation of metadata for each NFT. As parameters, make sure it accepts the pinCID
, pinName
, tokenTitle
, and tokenDescr
you have defined earlier.
For the first order of business for your writeJSON
function, create two temporary variables—audioIPFS
and coverIPFS
. These will be used to store the full IPFS gateway URLs, so you can display the image and audio file in the metadata. Don’t forget to check if there is a valid pinCID
and pinName
to avoid erroneous data.
Next, create a for
loop with pinName.length
being the determinant. Inside the loop, create an if-else
statement, checking if a given pinName
contains the .mp3
file extension. Set the audioIPFS
value to the gateway base URL, paired with the corresponding pinCID
if it’s an MP3, or coverIPFS
if it isn’t.
Considering the audio files generated are MP3s, you can use this to differentiate between audio and image file in a simple manner. Then, write the properly formatted metadata you have collected using fs.writeFileSync()
, and after checking everything was written correctly, return the temporary jsonMeta
object.
pinNFT
to run the entire process of pinning the NFT metadata. Within this function, wrap everything in a try
block to handle any errors that might occur during execution. Call the generateContent
function which should return an array containing all NFT data from a local metadata file and initialize an array nftURLs
to store the URLs of the pinned metadata.
Then, loop through each NFT in the allNFTs
array. For each NFT, extract the content
, tokenTitle
, and tokenDescr
fields. For each file in the content
, call the addFiles
function and wait for the retry time out. Afterwards, call the findCIDs
function and add the returned CID and filename to the pinCIDs
and pinNames
arrays respectively.
After all files for a particular NFT have been processed, call the writeJSON
function passing pinCIDs
, pinNames
, tokenTitle
, and tokenDescr
as arguments. This function should return a JSON metadata file. Then, call the addFiles
function again, this time for the JSON metadata file, and wait for the timeout before getting its CID using the findCIDs
function.
Finally, add the IPFS URL of the JSON metadata file to the nftURLs
array. After looping through all of them, write the nftURLs
array to a metadataURLs.json
file in the ./src/output/
directory. Don’t forget to call the pinNFT
function to start the pinning process.
metadataURLs.json
. Here’s the entire script to recap:
Preparing your mints
Now it’s time to move forward with the final script—mint.js
, so go ahead and create it, if you don’t have it already. Start by processing your dependencies, in this case, dotenv
, hardhat-web3
, fs
, and path
, then initialize your wallet address and private key by loading them from your .env
file.
contractAdrs
variable to set the smart contract address for the selected network. If you’re using the sepolia
network, set it to SEPOLIA_CONTRACT
, and for the Ethereum Mainnet, set it to MAINNET_CONTRACT
.
contractName
constant to store the name of your NFT contract, so it is easier to look up the appropriate Hardhat artifact. Read the artifact file and parse it as a JSON object to load the ABI
you will be needing further down the script.
Remember to load the metadataURLs.json
file as the value of a new global metadataUrls
constant and use the web3.eth.Contract
method to create a new contract object. Set the interactions origin to your Ethereum wallet address to be able to call the methods in your smart contract.
startMint
function where all the magic of minting happens. Proceed by creating an empty txUrls
array which will be used to store the URLs of all minting transactions, as well as a temporary nonce
variable, whose value should be set to await
the getTransactionCount
method with your address
as a parameter. This will prevent any errors caused by overlapping transactions.
Following that, iterate over every URL in the metadataUrls
array. For each of them, you’re going to mint an NFT. Within the loop, estimate the gas required to mint each NFT first. Then, create a transaction for the minting process, which you then sign using your private key.
Make sure you ask for your receipt too, once your transaction has been sent to the network successfully. Use the information from the receipt to generate a valid Etherscan URL for your transaction. Then, add the Etherscan URL to the txUrls
array, and increment the nonce for the next transaction.
Lastly, set a timeout after each NFT mint to allow for network propagation and write all transaction URLs to a mintTXs.json
file in the ./src/output/
directory. Naturally, don’t forget to call the startMint
function at the end too!
mint.js
script. You can find all files involved in the making of this tutorial in the full tutorial repo.