- 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 randomIndicesarray 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 jth 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 jth 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 jth 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 urland 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 responseas 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.jsonfile 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.
