How to mint a generative music NFT with Chainstack IPFS Storage and Soundraw

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.

npm i @nomiclabs/hardhat-web3 axios dotenv

Apart from these, however, you will also need an array of different libraries that you will use to make various transformations, generations, and requests. These are:

  • 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.
npm i [email protected] text-to-image jdenticon canvas image-data-uri merge-images

📘

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.

Once you’re done installing the packages, create a new generate.js file in your ./scripts directory, and process all dependencies like so:

// Process dependencies
require('dotenv').config();
require("@nomiclabs/hardhat-web3");

const fs = require('fs');
const fsPromises = fs.promises;
const path = require('path');
const https = require('https');
const randomWords = require('random-words'); // 1.3.0
const textToImage = require('text-to-image'); 
const jdenticon = require('jdenticon');
const { Canvas, Image } = require('canvas');
const ImageDataURI = require('image-data-uri');
const mergeImages = require('merge-images');
const FormData = require('form-data');
const axios = require('axios');

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:

# development
GOERLI="https://your-goerli-node-endpoint-here.com"
SEPOLIA="https://your-sepolia-node-endpoint-here.com"
MAINNET="https://your-ethereum-mainnet-node-endpoint-here.com"
CHAINSTACK="Bearer your.ChainstackAPIkeyHere"
PRIVATE_KEY="1337y0urWalletPrivateKeyHere1337"
WALLET="0xY0urWalletAddressHere1337"
GOERLI_CONTRACT="0xY0urGoerliNFTSmartContractAddressHere"
SEPOLIA_CONTRACT="0xY0urSepoliaNFTSmartContractAddressHere"
MAINNET_CONTRACT="Y0urEthereumMainnetNFTSmartContractAddressHere"
BUCKET_ID="BUCK-1337-8008-1337"
FOLDER_ID="FOLD-1337-8008-1337"
ETHERSCAN="Y0URETHERSCANAPIKEYHERE"
SOUNDRAW="Y0urSoundrawAPIKeyHEre=="

Then, return to your generate.js file and load the required environment variables after the dependency list:

// Load environment variables
const address = process.env.WALLET;
const soundraw = process.env.SOUNDRAW;

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:

// Initialize generation parameters
let randomHex;
let randomStr;
let wordNrs;
let digiRoot;
let wordsOut = '';
let colorHex = '#';
let bgColorHex = '#';
let shapeSides = '';
let shapeSize = '';
let shapeCtrX = '';
let shapeCtrY = '';
let shapeStroke = '#';
let shapeFill = '#';
let idHex = '';

Furthermore, you will also need to do the same to initialize the Soundraw API parameters—mood, genre, theme, length, file format, tempo, and energy levels but this time as constants since you won’t be changing these at any point during the tutorial. For this tutorial, you can exclude the 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.

// Soundraw parameters
const moods = ["Angry", "Busy & Frantic", "Dark", "Dreamy", "Elegant", "Epic", "Euphoric", "Fear", "Funny & Weird", "Glamorous", "Happy", "Heavy & Ponderous", "Hopeful", "Laid back", "Mysterious", "Peaceful", "Restless", "Romantic", "Running", "Sad", "Scary", "Sentimental", "Sexy", "Smooth", "Suspense"];
const genres = ["Acoustic", "Hip Hop", "Beats", "Funk", "Pop", "Drum n Bass", "Trap", "Tokyo night pop", "Rock", "Latin", "House", "Tropical House", "Ambient", "Orchestra", "Electro & Dance", "Electronica", "Techno & Trance"];
const themes = ["Ads & Trailers", "Broadcasting", "Cinematic", "Corporate", "Comedy", "Cooking", "Documentary", "Drama", "Fashion & Beauty", "Gaming", "Holiday season", "Horror & Thriller", "Motivational & Inspiring", "Nature", "Photography", "Sports & Action", "Technology", "Travel", "Tutorials", "Vlogs", "Wedding & Romance", "Workout & Wellness"];
const length = 77
const fileFormat = "mp3";
const tempo = ["low", "normal", "high"];
const energyLevels = ["Low", "Medium", "High", "Very High"];

With that taken care of, your generate.js file in the /scripts/ directory should look like this:

// Process dependencies
require('dotenv').config();
require("@nomiclabs/hardhat-web3");

const fs = require('fs');
const fsPromises = fs.promises;
const path = require('path');
const https = require('https');
const randomWords = require('random-words'); // 1.3.0
const textToImage = require('text-to-image'); 
const jdenticon = require('jdenticon');
const { Canvas, Image } = require('canvas');
const ImageDataURI = require('image-data-uri');
const mergeImages = require('merge-images');
const axios = require('axios');

// Load environment variables
const address = process.env.WALLET;
const soundraw = process.env.SOUNDRAW;

// Initialize generation parameters
let randomHex;
let randomStr;
let wordNrs;
let digiRoot;
let wordsOut = '';
let colorHex = '#';
let bgColorHex = '#';
let shapeSides = '';
let shapeSize = '';
let shapeCtrX = '';
let shapeCtrY = '';
let shapeStroke = '#';
let shapeFill = '#';
let idHex = '';

// Soundraw parameters
const moods = ["Angry", "Busy & Frantic", "Dark", "Dreamy", "Elegant", "Epic", "Euphoric", "Fear", "Funny & Weird", "Glamorous", "Happy", "Heavy & Ponderous", "Hopeful", "Laid back", "Mysterious", "Peaceful", "Restless", "Romantic", "Running", "Sad", "Scary", "Sentimental", "Sexy", "Smooth", "Suspense"];
const genres = ["Acoustic", "Hip Hop", "Beats", "Funk", "Pop", "Drum n Bass", "Trap", "Tokyo night pop", "Rock", "Latin", "House", "Tropical House", "Ambient", "Orchestra", "Electro & Dance", "Electronica", "Techno & Trance"];
const themes = ["Ads & Trailers", "Broadcasting", "Cinematic", "Corporate", "Comedy", "Cooking", "Documentary", "Drama", "Fashion & Beauty", "Gaming", "Holiday season", "Horror & Thriller", "Motivational & Inspiring", "Nature", "Photography", "Sports & Action", "Technology", "Travel", "Tutorials", "Vlogs", "Wedding & Romance", "Workout & Wellness"];
const length = 77
const fileFormat = "mp3";
const tempo = ["low", "normal", "high"];
const energyLevels = ["Low", "Medium", "High", "Very High"];

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 the randomHex() 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:

const generator = async() => {

    // Random generator layer 0: Seed preparations
    console.log('\nSeed generation started...\n');

    // Generate random hex with 20 bytes for symbols (same as wallet addresses)
    randomHex = web3.utils.randomHex(20).concat(address.slice(2));
    console.log('Random hex generated: ' + randomHex + '\n');
}

Next, it’s time to create and store a basic ID for each of your generations. You can do this by taking the randomHex seed value you just generated, then using the first and last three characters of the hex string to form the IDs:

// Generate ids for filenames to organize easier
    idHex = randomHex.slice(2, 5).concat(randomHex.slice(79, 82))
    console.log('Used hex to generate ID: ' + idHex + '\n');

In similar fashion, you can generate hex color values to feed the image generators further in your code. Let’s use a higher degree of randomization here by creating a loop that will run six times to form each of the six characters in a hex color value. You can use the same loop for both font and background colors:

// Generate random hex color value by picking random characters from the generated hex string
    for (var i = 0; i < 6; i++) {
        colorHex = colorHex.concat(randomHex.slice(2).charAt(Math.floor(Math.random() * randomHex.slice(2).length)));
        bgColorHex = bgColorHex.concat(randomHex.slice(2).charAt(Math.floor(Math.random() * randomHex.slice(2).length)));
    }
    console.log('Used hex to generate text color: ' + colorHex + ' & background color: ' + bgColorHex + '\n');

Once you’re done with the loop, you can go ahead and convert the random hex seed to a number string, so you can use it in another form to feed some of the generator libraries. To do this, use the web3.js hexToNumberString() method like so:

// Generate new string by combining the random hex output with wallet address and converting it to number string
    wordNrs = web3.utils.hexToNumberString(randomHex);
    console.log('Transformed hex into number string: ' + wordNrs + '\n');

This number string gives you an opportunity to use it for the Soundraw API audio generation for which you will need to create a loop. But before you start with the loop, make sure you have referenced a set of temporary variables that will be needed for it.

The first of these is the 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.

// Select Soundraw parameters based on the wordNrs number string
    let categories = [moods, genres, themes, tempo];
    let categoryNames = ['Mood', 'Genre', 'Theme', 'Tempo'];
    let numberOfTimeframes = 3;

    let requestPayload = {};

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 array randomIndices 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.

// Create a loop to generate a random index for the current category
    for (let i = 0; i < 4; i++) {

        // Create an array that will hold the randomIndex value for each iteration of the following loop
        let randomIndices = []

        // Iterate loop three times to reach all possible options with double-digit values
        for (let index = 0; index < 3; index++) {
            randomIndices[index] = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
        }

        // Sum the results from each iteration abd make sure they match the category length 
        let randomIndex = randomIndices.reduce((a, b) => a + b, 0);
        if (randomIndex >= categories[i].length) {
            randomIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomIndex >= categories[i].length || randomIndex < 0) {
                randomIndex = 0
            }
        } else if (randomIndex < 0) {
            randomIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomIndex >= categories[i].length || randomIndex < 0) {
                randomIndex = 0
            }
        }

        let categorySelected = categories[i][randomIndex];

        if (categoryNames[i] !== 'Tempo') {
            requestPayload[categoryNames[i].toLowerCase()] = categorySelected;
        } else {
            requestPayload.tempo = categorySelected;
        }
    }

Next comes a rather tricky part—how do you set a different energy level for the three timeframes that will define your audio generation. To do that, create an empty 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.

// Create arrays for holding the energy level objects and their lengths 
    let energyLevelsArray = []; 

    let lengths = [];
    for (let j = 0; j < numberOfTimeframes - 1; j++) {
        lengths.push(Math.random());
    }
    lengths.sort();

Next, create a temporary variable named 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.

// Adjust the lengths so they are proportional and add up to the audio length accordingly
    let previous = 0;
    for (let j = 0; j < numberOfTimeframes; j++) {
        lengths[j] = lengths[j] * length - previous;
        previous = lengths[j];
        lengths[j] = parseFloat(lengths[j].toFixed(1));
    }

Once ready, create another temporary variable 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.

let currentTime = 0;

    // Generate different energy levels for different timeframes
    for (let j = 0; j < numberOfTimeframes; j++) {
        let energyStart = parseFloat(currentTime.toFixed(1));
        let energyEnd = j < numberOfTimeframes - 1 ? parseFloat((currentTime + lengths[j]).toFixed(1)) : length;
        currentTime = energyEnd;

        // Apply the same logic as for randomIndex previously without the tripple iteration
        let randomEnergyIndex
            randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomEnergyIndex >= energyLevels.length) {
                randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
                if (randomEnergyIndex >= energyLevels.length || randomEnergyIndex < 0) {
                    randomEnergyIndex = 0
                }
            } else if (randomEnergyIndex < 0) {
                randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
                if (randomEnergyIndex >= energyLevels.length || randomEnergyIndex < 0) {
                    randomEnergyIndex = 0
                }
            }

        let selectedEnergy = energyLevels[randomEnergyIndex];

        energyLevelsArray.push({
            start: energyStart,
            end: energyEnd,
            energy: selectedEnergy
        });
    }

Lastly, finalize the audio generation process, by updating the 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.

// Update the request payload
    requestPayload.energy_levels = energyLevelsArray; 
    requestPayload.length = length;
    requestPayload.file_format = fileFormat;

    // Print selected parameters and make them the audio filename
    let filename = `${idHex} ${requestPayload.mood} ${requestPayload.genre} ${requestPayload.theme} ${requestPayload.tempo} [${length}s].mp3`;

As the final part of the audio generation process, you will need to send a 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.

// Submit an axios request to the Soundraw API and fetch the audio file
    console.log(`Attempting to submit request to Soundraw API with parameters ${JSON.stringify(requestPayload, null, 2)}\n`);
    axios({
        method: 'post',
        url: 'https://soundraw.io/api/v2/musics/compose',
        data: requestPayload,
        headers: {
            "Content-Type": "application/json",
            "Authorization": soundraw
        }
    })

Now, let's move on to how to handle the response from the Soundraw API. Use the 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.

.then(async function (response) {
            const audioFilePath = path.join('audio', filename);
            console.log(`Soundraw request successful. Response: ${JSON.stringify(response.data)}`);
            const formattedAudioFilePath = './src/' + audioFilePath.replace(/\\/g, "/"); // replace backslashes with forward slashes

Next, create a writable stream with 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.

After the file finishes downloading, attempt to update the local metadata with a custom function named updateLocalMetadata. If an error occurs during this process, log it to the console.

const file = fs.createWriteStream(path.join(__dirname, '../src', audioFilePath));
            const request = https.get(response.data.mp3_url, function (response) {
                response.pipe(file).on('finish', async function () {
                    // Call the function to update the JSON file
                    try {
                        console.log(`\nSoundraw audio saved to: ${formattedAudioFilePath}`);
                        await updateLocalMetadata(idHex, mergePath, formattedAudioFilePath, wordsOut, colorHex, digiRoot, requestPayload, length);
                    } catch (err) {
                        console.error(err);
                    }
                });
            });

Lastly, handle errors that may occur during the file write or HTTP request process. Both 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.

request.on('error', (err) => {
                console.error(`Request error: ${err}`);
                file.end();
            });
            file.on('error', (err) => {
                console.error(`File error: ${err}`);
                file.end();
            });
        })
        .catch(function (error) {
            console.log(error);
        });

This is a robust method of making an API request, handling the response, and properly taking care of any errors that might occur during the workflow. Here’s how the entire audio generation process looks, with the axios set up included:

// Soundraw parameters
const moods = ["Angry", "Busy & Frantic", "Dark", "Dreamy", "Elegant", "Epic", "Euphoric", "Fear", "Funny & Weird", "Glamorous", "Happy", "Heavy & Ponderous", "Hopeful", "Laid back", "Mysterious", "Peaceful", "Restless", "Romantic", "Running", "Sad", "Scary", "Sentimental", "Sexy", "Smooth", "Suspense"];
const genres = ["Acoustic", "Hip Hop", "Beats", "Funk", "Pop", "Drum n Bass", "Trap", "Tokyo night pop", "Rock", "Latin", "House", "Tropical House", "Ambient", "Orchestra", "Electro & Dance", "Electronica", "Techno & Trance"];
const themes = ["Ads & Trailers", "Broadcasting", "Cinematic", "Corporate", "Comedy", "Cooking", "Documentary", "Drama", "Fashion & Beauty", "Gaming", "Holiday season", "Horror & Thriller", "Motivational & Inspiring", "Nature", "Photography", "Sports & Action", "Technology", "Travel", "Tutorials", "Vlogs", "Wedding & Romance", "Workout & Wellness"];
const length = 77
const fileFormat = "mp3";
const tempo = ["low", "normal", "high"];
const energyLevels = ["Low", "Medium", "High", "Very High"];

const generator = async() => {

    // Random generator layer 0: Seed preparations
    console.log('\nSeed generation started...\n');

    // Generate random hex with 20 bytes for symbols (same as wallet addresses)
    randomHex = web3.utils.randomHex(20).concat(address.slice(2));
    console.log('Random hex generated: ' + randomHex + '\n');

    // Generate ids for filenames to organize easier
    idHex = randomHex.slice(2, 5).concat(randomHex.slice(79, 82))
    console.log('Used hex to generate ID: ' + idHex + '\n');

    // Generate random hex color value by picking random characters from the generated hex string
    for (var i = 0; i < 6; i++) {
        colorHex = colorHex.concat(randomHex.slice(2).charAt(Math.floor(Math.random() * randomHex.slice(2).length)));
        bgColorHex = bgColorHex.concat(randomHex.slice(2).charAt(Math.floor(Math.random() * randomHex.slice(2).length)));
    }
    console.log('Used hex to generate text color: ' + colorHex + ' & background color: ' + bgColorHex + '\n');

    // Generate new string by combining the random hex output with wallet address and converting it to number string
    wordNrs = web3.utils.hexToNumberString(randomHex);
    console.log('Transformed hex into number string: ' + wordNrs + '\n');

    // Select Soundraw parameters based on the wordNrs number string
    let categories = [moods, genres, themes, tempo];
    let categoryNames = ['Mood', 'Genre', 'Theme', 'Tempo'];
    let numberOfTimeframes = 3;

    let requestPayload = {};

    // Create a loop to generate a random index for the current category
    for (let i = 0; i < 4; i++) {

        // Create an array that will hold the randomIndex value for each iteration of the following loop
        let randomIndices = []

        // Iterate loop three times to reach all possible options with double-digit values
        for (let index = 0; index < 3; index++) {
            randomIndices[index] = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
        }

        // Sum the results from each iteration abd make sure they match the category length 
        let randomIndex = randomIndices.reduce((a, b) => a + b, 0);
        if (randomIndex >= categories[i].length) {
            randomIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomIndex >= categories[i].length || randomIndex < 0) {
                randomIndex = 0
            }
        } else if (randomIndex < 0) {
            randomIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomIndex >= categories[i].length || randomIndex < 0) {
                randomIndex = 0
            }
        }

        let categorySelected = categories[i][randomIndex];

        if (categoryNames[i] !== 'Tempo') {
            requestPayload[categoryNames[i].toLowerCase()] = categorySelected;
        } else {
            requestPayload.tempo = categorySelected;
        }
    }

    // Create arrays for holding the energy level objects and their lengths 
    let energyLevelsArray = []; 

    let lengths = [];
    for (let j = 0; j < numberOfTimeframes - 1; j++) {
        lengths.push(Math.random());
    }
    lengths.sort();

    // Adjust the lengths so they are proportional and add up to the audio length accordingly
    let previous = 0;
    for (let j = 0; j < numberOfTimeframes; j++) {
        lengths[j] = lengths[j] * length - previous;
        previous = lengths[j];
        lengths[j] = parseFloat(lengths[j].toFixed(1));
    }

    let currentTime = 0;

    // Generate different energy levels for different timeframes
    for (let j = 0; j < numberOfTimeframes; j++) {
        let energyStart = parseFloat(currentTime.toFixed(1));
        let energyEnd = j < numberOfTimeframes - 1 ? parseFloat((currentTime + lengths[j]).toFixed(1)) : length;
        currentTime = energyEnd;

        // Apply the same logic as for randomIndex previously without the tripple iteration
        let randomEnergyIndex
            randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomEnergyIndex >= energyLevels.length) {
                randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
                if (randomEnergyIndex >= energyLevels.length || randomEnergyIndex < 0) {
                    randomEnergyIndex = 0
                }
            } else if (randomEnergyIndex < 0) {
                randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
                if (randomEnergyIndex >= energyLevels.length || randomEnergyIndex < 0) {
                    randomEnergyIndex = 0
                }
            }

        let selectedEnergy = energyLevels[randomEnergyIndex];

        energyLevelsArray.push({
            start: energyStart,
            end: energyEnd,
            energy: selectedEnergy
        });
    }

    // Update the request payload
    requestPayload.energy_levels = energyLevelsArray; 
    requestPayload.length = length;
    requestPayload.file_format = fileFormat;

    // Print selected parameters and make them the audio filename
    let filename = `${idHex} ${requestPayload.mood} ${requestPayload.genre} ${requestPayload.theme} ${requestPayload.tempo} [${length}s].mp3`;

    // Submit an axios request to the Soundraw API and fetch the audio file
    console.log(`Attempting to submit request to Soundraw API with parameters ${JSON.stringify(requestPayload, null, 2)}\n`);
    axios({
        method: 'post',
        url: 'https://soundraw.io/api/v2/musics/compose',
        data: requestPayload,
        headers: {
            "Content-Type": "application/json",
            "Authorization": soundraw
        }
    })
        .then(async function (response) {
            const audioFilePath = path.join('audio', filename);
            console.log(`Soundraw request successful. Response: ${JSON.stringify(response.data)}`);
            const formattedAudioFilePath = './src/' + audioFilePath.replace(/\\/g, "/"); // replace backslashes with forward slashes
            const file = fs.createWriteStream(path.join(__dirname, '../src', audioFilePath));
            const request = https.get(response.data.mp3_url, function (response) {
                response.pipe(file).on('finish', async function () {
                    // Call the function to update the JSON file
                    try {
                        console.log(`\nSoundraw audio saved to: ${formattedAudioFilePath}`);
                        await updateLocalMetadata(idHex, mergePath, formattedAudioFilePath, wordsOut, colorHex, digiRoot, requestPayload, length);
                    } catch (err) {
                        console.error(err);
                    }
                });
            });
            request.on('error', (err) => {
                console.error(`Request error: ${err}`);
                file.end();
            });
            file.on('error', (err) => {
                console.error(`File error: ${err}`);
                file.end();
            });
        })
        .catch(function (error) {
            console.log(error);
        });

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 the wordNrs 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.

// Begin calculations for random shape layer generation parameters

    // Randomize shape parameters but ensure they are never zero

    // Find out the number of sides the shape has by picking a random number from the number string 
    shapeSides = parseInt(1 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)));
    console.log('Used number string to determine polygon shape sides count: ' + shapeSides + '\n');

    // Combine the first three digits of one of the two hex color values picked earlier with the last three of the other for greater variation
    shapeStroke = shapeStroke.concat(colorHex.slice(4, 7).concat(bgColorHex.slice(1, 4)));
    shapeFill = shapeFill.concat(bgColorHex.slice(4, 7).concat(colorHex.slice(1, 4)));
    console.log('Used text & background colors to generate new border: ' + shapeStroke + ' & fill: ' + shapeFill + '\n');

Then, run a loop twice to generate the size and center coordinates of the shape. For each property, randomly pick a digit from 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.

// Loop following calculations twice to generate double or higher digit values for the shape
    for (var i = 0; i < 2; i++) {

        // Avoid negative results by converting result to absolute value

        // Pick a random digit from the number string earlier, add the current shapeSize value, serve as float, multiply by Pi and add 10 for sizes between ~50 and ~150 for greater balance
        shapeSize = Math.abs(10 + Math.PI * parseFloat(shapeSize + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))));

        // Same as above except you substract 100 instead of adding 10. This will make the shape roll around the middle
        shapeCtrX = Math.abs(Math.PI * parseFloat(shapeCtrX + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))) - 100);
        shapeCtrY = Math.abs(Math.PI * parseFloat(shapeCtrY + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))) - 100);
    }

    console.log('Used number string to determine polygon shape size: ' + shapeSize + ' X-axis center value: ' + shapeCtrX + ' & Y-axis center value: ' + shapeCtrY + '\n');

After these calculations, define a 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.

// Reduce number string to single digit with the digital root formula
    function digitalRoot(input) {
        var nrStr = input.toString(),
            i,
            result = 0;

        if (nrStr.length === 1) {
            return +nrStr;
        }
        for (i = 0; i < nrStr.length; i++) {
            result += +nrStr[i];
        }
        return digitalRoot(result);
    }

Next, call your 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.

// Print digital root result
    digiRoot = digitalRoot(wordNrs);
    console.log('Calculated digital root of number string: ' + digiRoot + '\n');

    // Check if result is odd or even
    function NrChk(nr) {
        return nr % 2;
    }

    console.log('Checking if digital root is odd or even: ' + NrChk(digiRoot) + '\n');

    if (NrChk(digiRoot) > 0) {
        console.log('Generating 3 random words - digital root is odd\n');
    } else {
        console.log('Generating 2 random words - digital root is even\n');
    }

Once you are ready, it is time to move forward with the first generation process within the image layer—the text. Set the value of the 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.

// Random generator layer 1: Text

    // Generate set of random words - 2 for even 3 for odd. Since result will always be 0 or 1 easiest and fastest way is to just add 2. Replace "," with space for natural appeal
    randomStr = (randomWords(NrChk(digiRoot) + 2).toString()).split(',');
    console.log('Random words generated are: ' + randomStr + '\n');

    // Capitalize word set and join them as single string
    for (var i = 0; i < randomStr.length; i++) {
        randomStr[i] = (randomStr[i].charAt(0)).toUpperCase() + randomStr[i].slice(1);
    }
    wordsOut = randomStr.join(' ');
    
    console.log('Capitalizing random words string: ' + wordsOut + '\n');

After that, proceed by generating an image from the 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.

// Generate image from the random words, while using the library's debug mode to render to file
    var textPath = './src/texts/' + idHex + ' ' + wordsOut + ' ' + colorHex + ' [Text Layer].png';
    console.log('Exporting random words string as image to: ' + textPath + '\n');
    const dataUri = await textToImage.generate(idHex + ' ' + wordsOut, {
        debug: true,
        debugFilename: textPath,
        maxWidth: 330,
        customHeight: 33,
        fontSize: 18,
        fontFamily: 'Arial',
        lineHeight: 22,
        margin: 5,
        bgColor: bgColorHex,
        textColor: colorHex,
        textAlign: 'center',
        verticalAlign: 'top',
    });

Now, let's proceed to the second random generator layer—the Icon. First, set the icon parameters, which include 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.

// Random generator layer 2: Icon

    // Set icon parameters
    var iconSize = 350;
    var iconSeed = wordsOut;

    // Export icon to png
    const iconExport = jdenticon.toPng(iconSeed, iconSize);
    var iconPath = './src/icons/' + idHex + ' ' + wordsOut + ' ' + colorHex + ' [Icon Layer].png';
    
    console.log('Using random words string as seed to generate icon at: ' + iconPath + '\n');

    fs.writeFileSync(iconPath, iconExport);

With that behind you, move on by creating the final third layer of the image—the shape. To do that, create a new 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.

// Random generator Layer 3: Shape

    // Create new canvas object and set the context to 2d
    const shapeCanvas = new Canvas(350, 350);
    const shapeContext = shapeCanvas.getContext('2d');

    // Start drawing path on canvas
    console.log('Using polygon settings to draw path points & paint shape...\n');
    shapeContext.beginPath();

Afterwards, start moving the 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.

// Pick four incomprehensively generated points for the drawing path. Feel free to play around with the formula until you get desireable results
    shapeContext.moveTo(shapeCtrX + shapeSize * (Math.floor(Math.random() * 100 * Math.cos(shapeSides))), shapeCtrY + shapeSize * (Math.floor(Math.random() * 10 * Math.sin(shapeSides * shapeSize))), shapeCtrX + shapeSize * (Math.floor(Math.random() * 1000 * Math.tan(shapeCtrY * shapeSides))), shapeCtrY + shapeSize * (Math.floor(Math.random() * (1 / Math.tan(shapeCtrX * shapeSides)))));

    // Connect the path points according to randomly picked number of sides for the polygon
    for (var i = 1; i <= shapeSides; i++) {
        shapeContext.lineTo(shapeCtrX + shapeSize * Math.cos(i * 2 * Math.PI / shapeSides), shapeCtrY + shapeSize * Math.sin(i * 2 * Math.PI / shapeSides));
    }

Then, proceed by closing the drawing path, thus completing the polygon. Follow up by applying the 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.

// Close drawing path to complete the drawn object then proceed with applying border width and color, as well as fill color
    shapeContext.closePath();
    shapeContext.strokeStyle = shapeStroke;
    shapeContext.fillStyle = shapeFill;
    shapeContext.fill();
    shapeContext.lineWidth = shapeSides;
    shapeContext.stroke();

    // Record shape data URI to image buffer then render to preferred path
    const shapeBuffer = shapeCanvas.toBuffer("image/png");
    var shapePath = './src/shapes/' + shapeSides + ' ' + shapeStroke + '.png';
    console.log('Exporting polygon shape as image to: ' + shapePath + '\n');
    fs.writeFileSync(shapePath, shapeBuffer);

Here’s how your image generation layer part of the script should look like if you’ve done everything accordingly:

// Begin calculations for random shape layer generation parameters

    // Randomize shape parameters but ensure they are never zero

    // Find out the number of sides the shape has by picking a random number from the number string 
    shapeSides = parseInt(1 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)));
    console.log('Used number string to determine polygon shape sides count: ' + shapeSides + '\n');

    // Combine the first three digits of one of the two hex color values picked earlier with the last three of the other for greater variation
    shapeStroke = shapeStroke.concat(colorHex.slice(4, 7).concat(bgColorHex.slice(1, 4)));
    shapeFill = shapeFill.concat(bgColorHex.slice(4, 7).concat(colorHex.slice(1, 4)));
    console.log('Used text & background colors to generate new border: ' + shapeStroke + ' & fill: ' + shapeFill + '\n');

    // Loop following calculations twice to generate double or higher digit values for the shape
    for (var i = 0; i < 2; i++) {

        // Avoid negative results by converting result to absolute value

        // Pick a random digit from the number string earlier, add the current shapeSize value, serve as float, multiply by Pi and add 10 for sizes between ~50 and ~150 for greater balance
        shapeSize = Math.abs(10 + Math.PI * parseFloat(shapeSize + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))));

        // Same as above except you substract 100 instead of adding 10. This will make the shape roll around the middle
        shapeCtrX = Math.abs(Math.PI * parseFloat(shapeCtrX + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))) - 100);
        shapeCtrY = Math.abs(Math.PI * parseFloat(shapeCtrY + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))) - 100);
    }

    console.log('Used number string to determine polygon shape size: ' + shapeSize + ' X-axis center value: ' + shapeCtrX + ' & Y-axis center value: ' + shapeCtrY + '\n');

    // Reduce number string to single digit with the digital root formula
    function digitalRoot(input) {
        var nrStr = input.toString(),
            i,
            result = 0;

        if (nrStr.length === 1) {
            return +nrStr;
        }
        for (i = 0; i < nrStr.length; i++) {
            result += +nrStr[i];
        }
        return digitalRoot(result);
    }

    // Print digital root result
    digiRoot = digitalRoot(wordNrs);
    console.log('Calculated digital root of number string: ' + digiRoot + '\n');

    // Check if result is odd or even
    function NrChk(nr) {
        return nr % 2;
    }

    console.log('Checking if digital root is odd or even: ' + NrChk(digiRoot) + '\n');

    if (NrChk(digiRoot) > 0) {
        console.log('Generating 3 random words - digital root is odd\n');
    } else {
        console.log('Generating 2 random words - digital root is even\n');
    }
  
    // Random generator layer 1: Text

    // Generate set of random words - 2 for even 3 for odd. Since result will always be 0 or 1 easiest and fastest way is to just add 2. Replace "," with space for natural appeal
    randomStr = (randomWords(NrChk(digiRoot) + 2).toString()).split(',');
    console.log('Random words generated are: ' + randomStr + '\n');

    // Capitalize word set and join them as single string
    for (var i = 0; i < randomStr.length; i++) {
        randomStr[i] = (randomStr[i].charAt(0)).toUpperCase() + randomStr[i].slice(1);
    }
    wordsOut = randomStr.join(' ');
    console.log('Capitalizing random words string: ' + wordsOut + '\n');

    // Generate image from the random words, while using the library's debug mode to render to file

    // Exporting images to folders that do not exist yet may cause errors because of FS/OS permissions. Try creating them manually if you encounter such issue.
    var textPath = './src/texts/' + idHex + ' ' + wordsOut + ' ' + colorHex + ' [Text Layer].png';
    console.log('Exporting random words string as image to: ' + textPath + '\n');
    const dataUri = await textToImage.generate(idHex + ' ' + wordsOut, {
        debug: true,
        debugFilename: textPath,
        maxWidth: 330,
        customHeight: 33,
        fontSize: 18,
        fontFamily: 'Arial',
        lineHeight: 22,
        margin: 5,
        bgColor: bgColorHex,
        textColor: colorHex,
        textAlign: 'center',
        verticalAlign: 'top',
    });

    // Random generator layer 2: Icon

    // Set icon parameters
    var iconSize = 350;
    var iconSeed = wordsOut;

    // Export icon to png
    const iconExport = jdenticon.toPng(iconSeed, iconSize);
    var iconPath = './src/icons/' + idHex + ' ' + wordsOut + ' ' + colorHex + ' [Icon Layer].png';
    
    console.log('Using random words string as seed to generate icon at: ' + iconPath + '\n');

    fs.writeFileSync(iconPath, iconExport);

    // Random generator Layer 3: Shape

    // Create new canvas object and set the context to 2d
    const shapeCanvas = new Canvas(350, 350);
    const shapeContext = shapeCanvas.getContext('2d');

    // Start drawing path on canvas
    console.log('Using polygon settings to draw path points & paint shape...\n');
    shapeContext.beginPath();

    // Pick four incomprehensively generated points for the drawing path. Feel free to play around with the formula until you get desireable results
    shapeContext.moveTo(shapeCtrX + shapeSize * (Math.floor(Math.random() * 100 * Math.cos(shapeSides))), shapeCtrY + shapeSize * (Math.floor(Math.random() * 10 * Math.sin(shapeSides * shapeSize))), shapeCtrX + shapeSize * (Math.floor(Math.random() * 1000 * Math.tan(shapeCtrY * shapeSides))), shapeCtrY + shapeSize * (Math.floor(Math.random() * (1 / Math.tan(shapeCtrX * shapeSides)))));

    // Connect the path points according to randomly picked number of sides for the polygon
    for (var i = 1; i <= shapeSides; i++) {
        shapeContext.lineTo(shapeCtrX + shapeSize * Math.cos(i * 2 * Math.PI / shapeSides), shapeCtrY + shapeSize * Math.sin(i * 2 * Math.PI / shapeSides));
    }

    // Close drawing path to complete the drawn object then proceed with applying border width and color, as well as fill color
    shapeContext.closePath();
    shapeContext.strokeStyle = shapeStroke;
    shapeContext.fillStyle = shapeFill;
    shapeContext.fill();
    shapeContext.lineWidth = shapeSides;
    shapeContext.stroke();

    // Record shape data URI to image buffer then render to preferred path
    const shapeBuffer = shapeCanvas.toBuffer("image/png");
    var shapePath = './src/shapes/' + shapeSides + ' ' + shapeStroke + '.png';
    console.log('Exporting polygon shape as image to: ' + shapePath + '\n');
    fs.writeFileSync(shapePath, shapeBuffer);

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 the mergeImages 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.

// Merge existing layers by combining them in image buffer as data URI then output to file
    var mergePath = './src/merged/' + idHex + ' ' + wordsOut + ' ' + colorHex + ' [Merged].png';
    console.log('Merging all layers & exporting image to: ' + mergePath + '\n');
    mergeImages([shapePath, iconPath, textPath], {
        Canvas: Canvas,
        Image: Image
    }).then(function(response) {
        ImageDataURI.outputFile(response, mergePath)
    });

Once ready, go ahead and create a new asynchronous 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.

// Create a JSON with the locations of each generated set of media metadata 
    const updateLocalMetadata = async (idHex, coverPath, audioPath, wordsOut, colorHex, digiRoot, requestPayload, length) => {

        console.log(`\nAttempting to create JSON with local metadata details...`);

        const filePath = path.join(__dirname, '../src/output/local-metadata.json');

        try {
            const data = await fsPromises.readFile(filePath, 'utf8');

            // If the file exists, parse its content, add the new object, and write it back to the file
            const json = data ? JSON.parse(data) : {};
            json[idHex] = {
                name: `${idHex}: ${wordsOut}`,
                description: `A generative music NFT created with metadata seeds. Words: ${wordsOut}, Color: ${colorHex}, Digital Root: ${digiRoot}, Mood: ${requestPayload.mood}, Genre: ${requestPayload.genre}, Theme: ${requestPayload.theme}, Tempo: ${requestPayload.tempo}, Length: [${length}s]`,
                cover: coverPath,
                audio: audioPath
            };
            await fsPromises.writeFile(filePath, JSON.stringify(json, null, 2), 'utf8');

            console.log(`\nLocal metadata JSON created at ${filePath}...\n`);

        } catch (err) {
            if (err.code === 'ENOENT') {

                // If the file doesn't exist, initialize it as an empty object
                await fsPromises.writeFile(filePath, JSON.stringify({
                    [idHex]: {
                        name: `${idHex}: ${wordsOut}`,
                        description: `A generative music NFT created with metadata seeds. Words: ${wordsOut}, Color: ${colorHex}, Digital Root: ${digiRoot}, Mood: ${requestPayload.mood}, Genre: ${requestPayload.genre}, Theme: ${requestPayload.theme}, Tempo: ${requestPayload.tempo}, Length: [${length}s]`,
                        cover: coverPath,
                        audio: audioPath
                    }
                }, null, 2), 'utf8');

                console.log(`\nLocal metadata JSON created at ${filePath}...\n`);

            } else {
                throw err;
            }
        }
    };
};

That being said, the generation process is now complete, which means you get to put a definitive end to your generate.js script. Let’s recap with the entirety of the script to avoid something going AWOL somewhere along the way:

// Process dependencies
require('dotenv').config();
require("@nomiclabs/hardhat-web3");

const fs = require('fs');
const fsPromises = fs.promises;
const path = require('path');
const https = require('https');
const randomWords = require('random-words'); // 1.3.0
const textToImage = require('text-to-image'); 
const jdenticon = require('jdenticon');
const { Canvas, Image } = require('canvas');
const ImageDataURI = require('image-data-uri');
const mergeImages = require('merge-images');
const axios = require('axios');

// Load environment variables
const address = process.env.WALLET;
const soundraw = process.env.SOUNDRAW;

// Initialize generation parameters
let randomHex;
let randomStr;
let wordNrs;
let digiRoot;
let wordsOut = '';
let colorHex = '#';
let bgColorHex = '#';
let shapeSides = '';
let shapeSize = '';
let shapeCtrX = '';
let shapeCtrY = '';
let shapeStroke = '#';
let shapeFill = '#';
let idHex = '';

// Soundraw parameters
const moods = ["Angry", "Busy & Frantic", "Dark", "Dreamy", "Elegant", "Epic", "Euphoric", "Fear", "Funny & Weird", "Glamorous", "Happy", "Heavy & Ponderous", "Hopeful", "Laid back", "Mysterious", "Peaceful", "Restless", "Romantic", "Running", "Sad", "Scary", "Sentimental", "Sexy", "Smooth", "Suspense"];
const genres = ["Acoustic", "Hip Hop", "Beats", "Funk", "Pop", "Drum n Bass", "Trap", "Tokyo night pop", "Rock", "Latin", "House", "Tropical House", "Ambient", "Orchestra", "Electro & Dance", "Electronica", "Techno & Trance"];
const themes = ["Ads & Trailers", "Broadcasting", "Cinematic", "Corporate", "Comedy", "Cooking", "Documentary", "Drama", "Fashion & Beauty", "Gaming", "Holiday season", "Horror & Thriller", "Motivational & Inspiring", "Nature", "Photography", "Sports & Action", "Technology", "Travel", "Tutorials", "Vlogs", "Wedding & Romance", "Workout & Wellness"];
const length = 77
const fileFormat = "mp3";
const tempo = ["low", "normal", "high"];
const energyLevels = ["Low", "Medium", "High", "Very High"];

const generator = async() => {
    // Random generator layer 0: Seed preparations
    console.log('\nSeed generation started...\n');

    // Generate random hex with 20 bytes for symbols (same as wallet addresses)
    randomHex = web3.utils.randomHex(20).concat(address.slice(2));
    console.log('Random hex generated: ' + randomHex + '\n');

    // Generate ids for filenames to organize easier
    idHex = randomHex.slice(2, 5).concat(randomHex.slice(79, 82))
    console.log('Used hex to generate ID: ' + idHex + '\n');

    // Generate random hex color value by picking random characters from the generated hex string
    for (var i = 0; i < 6; i++) {
        colorHex = colorHex.concat(randomHex.slice(2).charAt(Math.floor(Math.random() * randomHex.slice(2).length)));
        bgColorHex = bgColorHex.concat(randomHex.slice(2).charAt(Math.floor(Math.random() * randomHex.slice(2).length)));
    }
    console.log('Used hex to generate text color: ' + colorHex + ' & background color: ' + bgColorHex + '\n');

    // Generate new string by combining the random hex output with wallet address and converting it to number string
    wordNrs = web3.utils.hexToNumberString(randomHex);
    console.log('Transformed hex into number string: ' + wordNrs + '\n');

    // Select Soundraw parameters based on the wordNrs number string
    let categories = [moods, genres, themes, tempo];
    let categoryNames = ['Mood', 'Genre', 'Theme', 'Tempo'];
    let numberOfTimeframes = 3;

    let requestPayload = {};

    // Create a loop to generate a random index for the current category
    for (let i = 0; i < 4; i++) {

        // Create an array that will hold the randomIndex value for each iteration of the following loop
        let randomIndices = []

        // Iterate loop three times to reach all possible options with double-digit values
        for (let index = 0; index < 3; index++) {
            randomIndices[index] = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
        }

        // Sum the results from each iteration abd make sure they match the category length 
        let randomIndex = randomIndices.reduce((a, b) => a + b, 0);
        if (randomIndex >= categories[i].length) {
            randomIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomIndex >= categories[i].length || randomIndex < 0) {
                randomIndex = 0
            }
        } else if (randomIndex < 0) {
            randomIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomIndex >= categories[i].length || randomIndex < 0) {
                randomIndex = 0
            }
        }

        let categorySelected = categories[i][randomIndex];

        if (categoryNames[i] !== 'Tempo') {
            requestPayload[categoryNames[i].toLowerCase()] = categorySelected;
        } else {
            requestPayload.tempo = categorySelected;
        }
    }

    // Create arrays for holding the energy level objects and their lengths 
    let energyLevelsArray = []; 

    let lengths = [];
    for (let j = 0; j < numberOfTimeframes - 1; j++) {
        lengths.push(Math.random());
    }
    lengths.sort();

    // Adjust the lengths so they are proportional and add up to the audio length accordingly
    let previous = 0;
    for (let j = 0; j < numberOfTimeframes; j++) {
        lengths[j] = lengths[j] * length - previous;
        previous = lengths[j];
        lengths[j] = parseFloat(lengths[j].toFixed(1));
    }

    let currentTime = 0;

    // Generate different energy levels for different timeframes
    for (let j = 0; j < numberOfTimeframes; j++) {
        let energyStart = parseFloat(currentTime.toFixed(1));
        let energyEnd = j < numberOfTimeframes - 1 ? parseFloat((currentTime + lengths[j]).toFixed(1)) : length;
        currentTime = energyEnd;

        // Apply the same logic as for randomIndex previously without the tripple iteration
        let randomEnergyIndex
            randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
            if (randomEnergyIndex >= energyLevels.length) {
                randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
                if (randomEnergyIndex >= energyLevels.length || randomEnergyIndex < 0) {
                    randomEnergyIndex = 0
                }
            } else if (randomEnergyIndex < 0) {
                randomEnergyIndex = parseInt(0 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))
                if (randomEnergyIndex >= energyLevels.length || randomEnergyIndex < 0) {
                    randomEnergyIndex = 0
                }
            }

        let selectedEnergy = energyLevels[randomEnergyIndex];

        energyLevelsArray.push({
            start: energyStart,
            end: energyEnd,
            energy: selectedEnergy
        });
    }

    // Update the request payload
    requestPayload.energy_levels = energyLevelsArray; 
    requestPayload.length = length;
    requestPayload.file_format = fileFormat;

    // Print selected parameters and make them the audio filename
    let filename = `${idHex} ${requestPayload.mood} ${requestPayload.genre} ${requestPayload.theme} ${requestPayload.tempo} [${length}s].mp3`;

    // Submit an axios request to the Soundraw API and fetch the audio file
    console.log(`Attempting to submit request to Soundraw API with parameters ${JSON.stringify(requestPayload, null, 2)}\n`);
    axios({
        method: 'post',
        url: 'https://soundraw.io/api/v2/musics/compose',
        data: requestPayload,
        headers: {
            "Content-Type": "application/json",
            "Authorization": soundraw
        }
    })
        .then(async function (response) {
            const audioFilePath = path.join('audio', filename);
            console.log(`Soundraw request successful. Response: ${JSON.stringify(response.data)}`);
            const formattedAudioFilePath = './src/' + audioFilePath.replace(/\\/g, "/"); // replace backslashes with forward slashes
            const file = fs.createWriteStream(path.join(__dirname, '../src', audioFilePath));
            const request = https.get(response.data.mp3_url, function (response) {
                response.pipe(file).on('finish', async function () {
                    // Call the function to update the JSON file
                    try {
                        console.log(`\nSoundraw audio saved to: ${formattedAudioFilePath}`);
                        await updateLocalMetadata(idHex, mergePath, formattedAudioFilePath, wordsOut, colorHex, digiRoot, requestPayload, length);
                    } catch (err) {
                        console.error(err);
                    }
                });
            });
            request.on('error', (err) => {
                console.error(`Request error: ${err}`);
                file.end();
            });
            file.on('error', (err) => {
                console.error(`File error: ${err}`);
                file.end();
            });
        })
        .catch(function (error) {
            console.log(error);
        });

    // Begin calculations for random shape layer generation parameters

    // Randomize shape parameters but ensure they are never zero

    // Find out the number of sides the shape has by picking a random number from the number string 
    shapeSides = parseInt(1 + wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)));
    console.log('Used number string to determine polygon shape sides count: ' + shapeSides + '\n');

    // Combine the first three digits of one of the two hex color values picked earlier with the last three of the other for greater variation
    shapeStroke = shapeStroke.concat(colorHex.slice(4, 7).concat(bgColorHex.slice(1, 4)));
    shapeFill = shapeFill.concat(bgColorHex.slice(4, 7).concat(colorHex.slice(1, 4)));
    console.log('Used text & background colors to generate new border: ' + shapeStroke + ' & fill: ' + shapeFill + '\n');

    // Loop following calculations twice to generate double or higher digit values for the shape
    for (var i = 0; i < 2; i++) {

        // Avoid negative results by converting result to absolute value

        // Pick a random digit from the number string earlier, add the current shapeSize value, serve as float, multiply by Pi and add 10 for sizes between ~50 and ~150 for greater balance
        shapeSize = Math.abs(10 + Math.PI * parseFloat(shapeSize + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))));

        // Same as above except you substract 100 instead of adding 10. This will make the shape roll around the middle
        shapeCtrX = Math.abs(Math.PI * parseFloat(shapeCtrX + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))) - 100);
        shapeCtrY = Math.abs(Math.PI * parseFloat(shapeCtrY + parseInt(wordNrs.charAt(Math.floor(Math.random() * wordNrs.length)))) - 100);
    }

    console.log('Used number string to determine polygon shape size: ' + shapeSize + ' X-axis center value: ' + shapeCtrX + ' & Y-axis center value: ' + shapeCtrY + '\n');

    // Reduce number string to single digit with the digital root formula
    function digitalRoot(input) {
        var nrStr = input.toString(),
            i,
            result = 0;

        if (nrStr.length === 1) {
            return +nrStr;
        }
        for (i = 0; i < nrStr.length; i++) {
            result += +nrStr[i];
        }
        return digitalRoot(result);
    }

    // Print digital root result
    digiRoot = digitalRoot(wordNrs);
    console.log('Calculated digital root of number string: ' + digiRoot + '\n');

    // Check if result is odd or even
    function NrChk(nr) {
        return nr % 2;
    }

    console.log('Checking if digital root is odd or even: ' + NrChk(digiRoot) + '\n');

    if (NrChk(digiRoot) > 0) {
        console.log('Generating 3 random words - digital root is odd\n');
    } else {
        console.log('Generating 2 random words - digital root is even\n');
    }
  
    // Random generator layer 1: Text

    // Generate set of random words - 2 for even 3 for odd. Since result will always be 0 or 1 easiest and fastest way is to just add 2. Replace "," with space for natural appeal
    randomStr = (randomWords(NrChk(digiRoot) + 2).toString()).split(',');
    console.log('Random words generated are: ' + randomStr + '\n');

    // Capitalize word set and join them as single string
    for (var i = 0; i < randomStr.length; i++) {
        randomStr[i] = (randomStr[i].charAt(0)).toUpperCase() + randomStr[i].slice(1);
    }
    wordsOut = randomStr.join(' ');
    console.log('Capitalizing random words string: ' + wordsOut + '\n');

    // Generate image from the random words, while using the library's debug mode to render to file

    // Exporting images to folders that do not exist yet may cause errors because of FS/OS permissions. Try creating them manually if you encounter such issue.
    var textPath = './src/texts/' + idHex + ' ' + wordsOut + ' ' + colorHex + ' [Text Layer].png';
    console.log('Exporting random words string as image to: ' + textPath + '\n');
    const dataUri = await textToImage.generate(idHex + ' ' + wordsOut, {
        debug: true,
        debugFilename: textPath,
        maxWidth: 330,
        customHeight: 33,
        fontSize: 18,
        fontFamily: 'Arial',
        lineHeight: 22,
        margin: 5,
        bgColor: bgColorHex,
        textColor: colorHex,
        textAlign: 'center',
        verticalAlign: 'top',
    });

    // Random generator layer 2: Icon

    // Set icon parameters
    var iconSize = 350;
    var iconSeed = wordsOut;

    // Export icon to png
    const iconExport = jdenticon.toPng(iconSeed, iconSize);
    var iconPath = './src/icons/' + idHex + ' ' + wordsOut + ' ' + colorHex + ' [Icon Layer].png';
    
    console.log('Using random words string as seed to generate icon at: ' + iconPath + '\n');

    fs.writeFileSync(iconPath, iconExport);

    // Random generator Layer 3: Shape

    // Create new canvas object and set the context to 2d
    const shapeCanvas = new Canvas(350, 350);
    const shapeContext = shapeCanvas.getContext('2d');

    // Start drawing path on canvas
    console.log('Using polygon settings to draw path points & paint shape...\n');
    shapeContext.beginPath();

    // Pick four incomprehensively generated points for the drawing path. Feel free to play around with the formula until you get desireable results
    shapeContext.moveTo(shapeCtrX + shapeSize * (Math.floor(Math.random() * 100 * Math.cos(shapeSides))), shapeCtrY + shapeSize * (Math.floor(Math.random() * 10 * Math.sin(shapeSides * shapeSize))), shapeCtrX + shapeSize * (Math.floor(Math.random() * 1000 * Math.tan(shapeCtrY * shapeSides))), shapeCtrY + shapeSize * (Math.floor(Math.random() * (1 / Math.tan(shapeCtrX * shapeSides)))));

    // Connect the path points according to randomly picked number of sides for the polygon
    for (var i = 1; i <= shapeSides; i++) {
        shapeContext.lineTo(shapeCtrX + shapeSize * Math.cos(i * 2 * Math.PI / shapeSides), shapeCtrY + shapeSize * Math.sin(i * 2 * Math.PI / shapeSides));
    }

    // Close drawing path to complete the drawn object then proceed with applying border width and color, as well as fill color
    shapeContext.closePath();
    shapeContext.strokeStyle = shapeStroke;
    shapeContext.fillStyle = shapeFill;
    shapeContext.fill();
    shapeContext.lineWidth = shapeSides;
    shapeContext.stroke();

    // Record shape data URI to image buffer then render to preferred path
    const shapeBuffer = shapeCanvas.toBuffer("image/png");
    var shapePath = './src/shapes/' + shapeSides + ' ' + shapeStroke + '.png';
    console.log('Exporting polygon shape as image to: ' + shapePath + '\n');
    fs.writeFileSync(shapePath, shapeBuffer);

    // Merge existing layers by combining them in image buffer as data URI then output to file
    var mergePath = './src/merged/' + idHex + ' ' + wordsOut + ' ' + colorHex + ' [Merged].png';
    console.log('Merging all layers & exporting image to: ' + mergePath + '\n');
    mergeImages([shapePath, iconPath, textPath], {
        Canvas: Canvas,
        Image: Image
    }).then(function(response) {
        ImageDataURI.outputFile(response, mergePath)
    });

    // Create a JSON with the locations of each generated set of media metadata 
    const updateLocalMetadata = async (idHex, coverPath, audioPath, wordsOut, colorHex, digiRoot, requestPayload, length) => {

        console.log(`\nAttempting to create JSON with local metadata details...`);

        const filePath = path.join(__dirname, '../src/local-metadata.json');

        try {
            const data = await fsPromises.readFile(filePath, 'utf8');

            // If the file exists, parse its content, add the new object, and write it back to the file
            const json = data ? JSON.parse(data) : {};
            json[idHex] = {
                name: `${idHex}: ${wordsOut}`,
                description: `A generative music NFT created with metadata seeds. Words: ${wordsOut}, Color: ${colorHex}, Digital Root: ${digiRoot}, Mood: ${requestPayload.mood}, Genre: ${requestPayload.genre}, Theme: ${requestPayload.theme}, Tempo: ${requestPayload.tempo}, Length: [${length}s]`,
                cover: coverPath,
                audio: audioPath
            };
            await fsPromises.writeFile(filePath, JSON.stringify(json, null, 2), 'utf8');

            console.log(`\nLocal metadata JSON created at ${filePath}`);

        } catch (err) {
            if (err.code === 'ENOENT') {

                // If the file doesn't exist, initialize it as an empty object
                await fsPromises.writeFile(filePath, JSON.stringify({
                    [idHex]: {
                        name: `${idHex}: ${wordsOut}`,
                        description: `A generative music NFT created with metadata seeds. Words: ${wordsOut}, Color: ${colorHex}, Digital Root: ${digiRoot}, Mood: ${requestPayload.mood}, Genre: ${requestPayload.genre}, Theme: ${requestPayload.theme}, Tempo: ${requestPayload.tempo}, Length: [${length}s]`,
                        cover: coverPath,
                        audio: audioPath
                    }
                }, null, 2), 'utf8');

                console.log(`\nLocal metadata JSON created at ${filePath}`);

            } else {
                throw err;
            }
        }
    };
};

// Don't forget to run the entire process!
generator();

Step 3: Pinning to Chainstack IPFS and minting

First things first, create a new pin.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.

// Process dependencies
require('dotenv').config();
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');

Next, create an asynchronous function, 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.

// Define the media files to be pinned
async function generateContent() {
  const data = await fsPromises.readFile(path.join(__dirname, '../src/output/local-metadata.json'), 'utf8');
  const json = JSON.parse(data);

  let allContent = [];

  for (const key in json) {
    if (json.hasOwnProperty(key)) {
      const element = json[key];
      const content = [];

      const coverTitle = path.basename(element.cover);
      const audioTitle = path.basename(element.audio);
      const tokenTitle = element.name;
      const tokenDescr = element.description;

      content.push({
        file: fs.createReadStream(path.join(__dirname, '..', element.cover)),
        title: coverTitle
      });

      content.push({
        file: fs.createReadStream(path.join(__dirname, '..', element.audio)),
        title: audioTitle
      });

      allContent.push({
        content: content,
        tokenTitle: tokenTitle,
        tokenDescr: tokenDescr
      });
    }
  }

  return allContent;
}

Having established the base data in 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.

// Define a function to pin files with Chainstack IPFS Storage
const addFiles = async (source, single = false) => {
  const url = single ? "https://api.chainstack.com/v1/ipfs/pins/pinfile"
    : "https://api.chainstack.com/v1/ipfs/pins/pinfiles";
  const pubIDs = [];
  const maxRetries = 7;
  const retryTimeout = 22222;

  for (let file of source) {
    let retries = 0;

    while (retries < maxRetries) {
      try {
        console.log(`Attempting to pin ${file.title} with Chainstack IPFS Storage... Attempt number: ${retries + 1}\n`);

        const data = new FormData();
        data.append('bucket_id', process.env.BUCKET_ID);
        data.append('folder_id', process.env.FOLDER_ID);
        data.append('file', file.file);
        data.append('title', file.title);

Create a configuration object for the HTTP request. It includes the request method, URL, headers, and data. The headers contain the content type, authorization, and other headers required by the 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.

const config = {
          method: 'POST',
          url: url,
          headers: {
            "Content-Type": 'multipart/form-data;',
            "Authorization": process.env.CHAINSTACK,
            ...data.getHeaders()
          },
          data: data
        };

        const response = await axios(config);

        let id;
        if (single) {
          console.log(`Successfully pinned ${file.title} with Chainstack IPFS Storage using public ID: ${JSON.stringify(response.data.id)}\n`);
          id = response.data.id;
          id = Array.isArray(id) ? id : [id];
        } else {
          console.log(`Successfully pinned ${file.title} with Chainstack IPFS Storage using public ID: ${JSON.stringify(response.data[0].id)}\n`);
          id = response.data[0].id;
        }

        pubIDs.push(id);

        // If successful, break the loop
        break;
      } catch (error) {
        console.error(`Error in addFiles: ${error.message}.. Attempting to retry...\n`);

        // Retry after the timeout if unsuccessful
        retries++;
        console.log(`Retrying after error. Current retry count is: ${retries}`);
        await new Promise((resolve) => setTimeout(resolve, retryTimeout));

        // If max retries is reached and still failing, throw the error
        if (retries === maxRetries) {
          throw new Error(`Failed after ${maxRetries} attempts. ${error.message}`);
        }
      }
    }
  }

  return pubIDs;
};

Continue by defining an asynchronous 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.

// Define a function to find CIDs for files pinned with Chainstack IPFS Storage
const findCIDs = async (fileID, single = false) => {
  if (single) {
    fileID = fileID.replace(/"/g, '');
    fileID = Array.isArray(fileID) ? fileID : [fileID];

  }

  // Define the maximum retries and the timeout between retries
  const maxRetries = 7;
  const retryTimeout = 22222;

  if (!single) {
    let cid = [];
    let name = [];

    // Loop through all the pinned files
    for (var i = 0; i < fileID.length; i++) {

      // Get the CID and filename for the file
      const result = await findCIDs(fileID[i], true);
      cid.push(result[0]);
      name.push(result[1]);
    }

    // Print the CIDs found and return the cid and name values
    console.log(`All CIDs found: ${cid.join(', ')}\n`);
    return [cid, name];
  } else {
    let cid;
    let name;
    let retries = 0;

    // Set up the retry loop
    while (retries < maxRetries) {
      try {
        console.log(`Attempting to find CID using public ID: ${fileID} with Chainstack IPFS Storage...\n`);

        // Define the Axios configuration
        const url = "https://api.chainstack.com/v1/ipfs/pins/" + fileID;
        var config = {
          method: 'GET',
          url: url,
          headers: {
            "Content-Type": 'text/plain',
            "Authorization": process.env.CHAINSTACK,
            "Accept-Encoding": 'identity',
          },
          decompress: false 
        };

        // Store the Axios response
        const response = await axios(config);
        console.log(`CID found: ${response.data.cid} Filename: ${response.data.title}\n`);

        cid = response.data.cid;
        name = response.data.title;

        // Throw an error if the cid and name values are not valid
        if (cid != null && cid !== 'error' && name != null && name !== 'error') {
          break;
        } else {

          // Throw an error if the CID and filename are not valid
          throw new Error('CID or name values are not valid.');
        }
      } catch (error) {
        console.error(`Error in findCIDs: ${error.message}.. Attempting to retry...\n`);

        // Retry after the timeout if unsuccessful
        retries++;
        await new Promise((resolve) => setTimeout(resolve, retryTimeout));
      }
    }
    return [cid, name];
  }
};

After the CIDs have been found, proceed to create an asynchronous 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.

// Define a function to write the metadata to a .json file
const writeJSON = async (pinCID, pinName, tokenTitle, tokenDescr) => {
  let audioIPFS;
  let coverIPFS;
  if (pinCID && pinName) {
    for (var i = 0; i < pinName.length; i++) {
      if (pinName[i].includes('mp3')) {
        audioIPFS = "https://ipfsgw.com/ipfs/" + pinCID[i];
      } else {
        coverIPFS = "https://ipfsgw.com/ipfs/" + pinCID[i];
      }
    }

    // Write the metadata to the file ./src/NFTmetadata.json
    fs.writeFileSync(`./src/jsons/${tokenTitle.replace(/:/g, '')}.json`, JSON.stringify({
      "description": tokenDescr,
      "external_url": "https://chainstack.com/nfts/",
      "image": coverIPFS,
      "animation_url": audioIPFS,
      "name": tokenTitle
    }));

    let jsonMeta;
    if (fs.existsSync(`./src/jsons/${tokenTitle.replace(/:/g, '')}.json`)) {
      jsonMeta = {
        file: fs.createReadStream(`./src/jsons/${tokenTitle.replace(/:/g, '')}.json`),
        title: `${tokenTitle.replace(/:/g, '')}.json`
      };
    }
    return jsonMeta;
  }
};

Lastly, define the main asynchronous function 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.

// Define the main function that executes all necessary functions to pin the NFT metadata
const pinNFT = async () => {
  try {

    // Generate the content from local metadata file
    const allNFTs = await generateContent();

    // Initialize array to store the pinned metadata urls
    let nftURLs = [];

    for (let nft of allNFTs) {
      const { content, tokenTitle, tokenDescr } = nft;
      let pinCIDs = [];
      let pinNames = [];

      // Ensure all files for this entry are pinned before moving on to the next
      for (let file of content) {
        const ids = await addFiles([file]);
        await new Promise((resolve) => setTimeout(resolve, 22222));

        const [pinCID, pinName] = await findCIDs(ids);
        pinCIDs.push(pinCID[0]);
        pinNames.push(pinName[0]);
        await new Promise((resolve) => setTimeout(resolve, 22222));
      }

      const jsonMeta = await writeJSON(pinCIDs, pinNames, tokenTitle, tokenDescr);
      await new Promise((resolve) => setTimeout(resolve, 22222));

      const id = await addFiles([jsonMeta]);
      await new Promise((resolve) => setTimeout(resolve, 22222));

      const jsonCID = await findCIDs(id);
      console.log(`NFT metadata for ${tokenTitle} successfully pinned with Chainstack IPFS Storage!\n`);

      // Add the metadata URL to the nftURLs array
      nftURLs.push(`https://ipfsgw.com/ipfs/${jsonCID[0]}`);
    }

    // Write the metadata URLs to JSON 
    console.log(`Writing metadata URL to ./src/output/metadataURLs.json...\n`);
    fs.writeFileSync('./src/output/metadataURLs.json', JSON.stringify(nftURLs, null, 2));
  } catch (error) {
    console.error(`Error during NFT pinning: ${JSON.stringify(error)}`);
  }
};

// Don't forget to run the main function!
pinNFT();

Once the script finishes running, the metadata for all NFTs will have been successfully pinned to Chainstack IPFS Storage, and their URLs will be saved in metadataURLs.json. Here’s the entire script to recap:

// Process dependencies
require('dotenv').config();
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');

// Define the media files to be pinned
async function generateContent() {
  const data = await fsPromises.readFile(path.join(__dirname, '../src/output/local-metadata.json'), 'utf8');
  const json = JSON.parse(data);

  let allContent = [];

  for (const key in json) {
    if (json.hasOwnProperty(key)) {
      const element = json[key];
      const content = [];

      const coverTitle = path.basename(element.cover);
      const audioTitle = path.basename(element.audio);
      const tokenTitle = element.name;
      const tokenDescr = element.description;

      content.push({
        file: fs.createReadStream(path.join(__dirname, '..', element.cover)),
        title: coverTitle
      });

      content.push({
        file: fs.createReadStream(path.join(__dirname, '..', element.audio)),
        title: audioTitle
      });

      allContent.push({
        content: content,
        tokenTitle: tokenTitle,
        tokenDescr: tokenDescr
      });
    }
  }

  return allContent;
}

// Define a function to pin files with Chainstack IPFS Storage
const addFiles = async (source, single = false) => {
  const url = single ? "https://api.chainstack.com/v1/ipfs/pins/pinfile"
    : "https://api.chainstack.com/v1/ipfs/pins/pinfiles";
  const pubIDs = [];
  const maxRetries = 7;
  const retryTimeout = 22222;

  for (let file of source) {
    let retries = 0;

    while (retries < maxRetries) {
      try {
        console.log(`Attempting to pin ${file.title} with Chainstack IPFS Storage... Attempt number: ${retries + 1}\n`);

        const data = new FormData();
        data.append('bucket_id', process.env.BUCKET_ID);
        data.append('folder_id', process.env.FOLDER_ID);
        data.append('file', file.file);
        data.append('title', file.title);

        const config = {
          method: 'POST',
          url: url,
          headers: {
            "Content-Type": 'multipart/form-data;',
            "Authorization": process.env.CHAINSTACK,
            ...data.getHeaders()
          },
          data: data
        };

        const response = await axios(config);

        let id;
        if (single) {
          console.log(`Successfully pinned ${file.title} with Chainstack IPFS Storage using public ID: ${JSON.stringify(response.data.id)}\n`);
          id = response.data.id;
          id = Array.isArray(id) ? id : [id];
        } else {
          console.log(`Successfully pinned ${file.title} with Chainstack IPFS Storage using public ID: ${JSON.stringify(response.data[0].id)}\n`);
          id = response.data[0].id;
        }

        pubIDs.push(id);

        // If successful, break the loop
        break;
      } catch (error) {
        console.error(`Error in addFiles: ${error.message}.. Attempting to retry...\n`);

        // Retry after the timeout if unsuccessful
        retries++;
        console.log(`Retrying after error. Current retry count is: ${retries}`);
        await new Promise((resolve) => setTimeout(resolve, retryTimeout));

        // If max retries is reached and still failing, throw the error
        if (retries === maxRetries) {
          throw new Error(`Failed after ${maxRetries} attempts. ${error.message}`);
        }
      }
    }
  }

  return pubIDs;
};

// Define a function to find CIDs for files pinned with Chainstack IPFS Storage
const findCIDs = async (fileID, single = false) => {
  if (single) {
    fileID = fileID.replace(/"/g, '');
    fileID = Array.isArray(fileID) ? fileID : [fileID];

  }

  // Define the maximum retries and the timeout between retries
  const maxRetries = 7;
  const retryTimeout = 22222;

  if (!single) {
    let cid = [];
    let name = [];

    // Loop through all the pinned files
    for (var i = 0; i < fileID.length; i++) {

      // Get the CID and filename for the file
      const result = await findCIDs(fileID[i], true);
      cid.push(result[0]);
      name.push(result[1]);
    }

    // Print the CIDs found and return the cid and name values
    console.log(`All CIDs found: ${cid.join(', ')}\n`);
    return [cid, name];
  } else {
    let cid;
    let name;
    let retries = 0;

    // Set up the retry loop
    while (retries < maxRetries) {
      try {
        console.log(`Attempting to find CID using public ID: ${fileID} with Chainstack IPFS Storage...\n`);

        // Define the Axios configuration
        const url = "https://api.chainstack.com/v1/ipfs/pins/" + fileID;
        var config = {
          method: 'GET',
          url: url,
          headers: {
            "Content-Type": 'text/plain',
            "Authorization": process.env.CHAINSTACK,
            "Accept-Encoding": 'identity',
          },
          decompress: false 
        };

        // Store the Axios response
        const response = await axios(config);
        console.log(`CID found: ${response.data.cid} Filename: ${response.data.title}\n`);

        cid = response.data.cid;
        name = response.data.title;

        // Throw an error if the cid and name values are not valid
        if (cid != null && cid !== 'error' && name != null && name !== 'error') {
          break;
        } else {

          // Throw an error if the CID and filename are not valid
          throw new Error('CID or name values are not valid.');
        }
      } catch (error) {
        console.error(`Error in findCIDs: ${error.message}.. Attempting to retry...\n`);

        // Retry after the timeout if unsuccessful
        retries++;
        await new Promise((resolve) => setTimeout(resolve, retryTimeout));
      }
    }
    return [cid, name];
  }
};

// Define a function to write the metadata to a .json file
const writeJSON = async (pinCID, pinName, tokenTitle, tokenDescr) => {
  let audioIPFS;
  let coverIPFS;
  if (pinCID && pinName) {
    for (var i = 0; i < pinName.length; i++) {
      if (pinName[i].includes('mp3')) {
        audioIPFS = "https://ipfsgw.com/ipfs/" + pinCID[i];
      } else {
        coverIPFS = "https://ipfsgw.com/ipfs/" + pinCID[i];
      }
    }

    // Write the metadata to the file ./src/NFTmetadata.json
    fs.writeFileSync(`./src/jsons/${tokenTitle.replace(/:/g, '')}.json`, JSON.stringify({
      "description": tokenDescr,
      "external_url": "https://chainstack.com/nfts/",
      "image": coverIPFS,
      "animation_url": audioIPFS,
      "name": tokenTitle
    }));

    let jsonMeta;
    if (fs.existsSync(`./src/jsons/${tokenTitle.replace(/:/g, '')}.json`)) {
      jsonMeta = {
        file: fs.createReadStream(`./src/jsons/${tokenTitle.replace(/:/g, '')}.json`),
        title: `${tokenTitle.replace(/:/g, '')}.json`
      };
    }
    return jsonMeta;
  }
};

// Define the main function that executes all necessary functions to pin the NFT metadata
const pinNFT = async () => {
  try {

    // Generate the content from local metadata file
    const allNFTs = await generateContent();

    // Initialize array to store the pinned metadata urls
    let nftURLs = [];

    for (let nft of allNFTs) {
      const { content, tokenTitle, tokenDescr } = nft;
      let pinCIDs = [];
      let pinNames = [];

      // Ensure all files for this entry are pinned before moving on to the next
      for (let file of content) {
        const ids = await addFiles([file]);
        await new Promise((resolve) => setTimeout(resolve, 22222));

        const [pinCID, pinName] = await findCIDs(ids);
        pinCIDs.push(pinCID[0]);
        pinNames.push(pinName[0]);
        await new Promise((resolve) => setTimeout(resolve, 22222));
      }

      const jsonMeta = await writeJSON(pinCIDs, pinNames, tokenTitle, tokenDescr);
      await new Promise((resolve) => setTimeout(resolve, 22222));

      const id = await addFiles([jsonMeta]);
      await new Promise((resolve) => setTimeout(resolve, 22222));

      const jsonCID = await findCIDs(id);
      console.log(`NFT metadata for ${tokenTitle} successfully pinned with Chainstack IPFS Storage!\n`);

      // Add the metadata URL to the nftURLs array
      nftURLs.push(`https://ipfsgw.com/ipfs/${jsonCID[0]}`);
    }

    // Write the metadata URLs to JSON 
    console.log(`Writing metadata URL to ./src/output/metadataURLs.json...\n`);
    fs.writeFileSync('./src/output/metadataURLs.json', JSON.stringify(nftURLs, null, 2));
  } catch (error) {
    console.error(`Error during NFT pinning: ${JSON.stringify(error)}`);
  }
};

// Don't forget to run the main function!
pinNFT();

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.

// Process dependencies
require('dotenv').config();
require("@nomiclabs/hardhat-web3");
const fs = require('fs');
const path = require('path');

// Initialize your wallet address and private key
const address = process.env.WALLET;
const privKey = process.env.PRIVATE_KEY;

Next, create a new globalcontractAdrs variable to set the smart contract address for the selected network. If you're using the sepolia network, set it to SEPOLIA_CONTRACT, for the goerli network, set it to GOERLI_CONTRACT, and for the Ethereum Mainnet, set it to MAINNET_CONTRACT.

// Initialize your deployed smart contract address for the selected network
let contractAdrs;
if (network.name == 'sepolia') {
  const contractENV = process.env.SEPOLIA_CONTRACT
  contractAdrs = contractENV;
} else if (network.name == 'goerli') {
  const contractENV = process.env.GOERLI_CONTRACT;
  contractAdrs = contractENV;
} else {
  const contractENV = process.env.MAINNET_CONTRACT;
  contractAdrs = contractENV;
}

Then, create a new global 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.

// Replace 'MyFirstMusicNFT' with your contract's name.
const contractName = 'MyFirstMusicNFT';

// Find the compiled smart contract to get the ABI
const artifactPath = path.resolve(__dirname, `../artifacts/contracts/${contractName}.sol/${contractName}.json`);
const contractArtifact = JSON.parse(fs.readFileSync(artifactPath, 'utf-8'));
const contractABI = contractArtifact.abi;

// Load metadata URLs from file
const metadataUrls = require('../src/output/metadataURLs.json');

// Create a new contract object and set interactions origin to the owner address
const contractObj = new web3.eth.Contract(contractABI, contractAdrs, {
  from: address,
});

Afterwards, define an asynchronous 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!

// Define the minting function
const startMint = async () => {
  console.log(`\nAttempting to mint on ${network.name} to: ${address}...\n`);

  // Create an array to store all transaction URLs
  let txUrls = [];

  // Get the current transaction count, which will serve as the initial nonce
  let nonce = await web3.eth.getTransactionCount(address);

  // Iterate over each metadata URL to mint NFT
  for (const metadata of metadataUrls) {

    // Estimate the gas costs needed to process the transaction
    const gasCost = await contractObj.methods.safeMint(address, metadata).estimateGas((err, gas) => {
      if (!err) console.log(`Estimated gas: ${gas} for metadata: ${metadata}\n`);
      else console.error(`Error estimating gas: ${err} for metadata: ${metadata}\n`);
    });

    // Define the transaction details and sign it
    const mintTX = await web3.eth.accounts.signTransaction(
      {
        from: address,
        to: contractAdrs,
        data: contractObj.methods.safeMint(address, metadata).encodeABI(),
        gas: gasCost,
        nonce: nonce, 
      },
      privKey,
    );

    // Get transaction receipt
    const createReceipt = await web3.eth.sendSignedTransaction(mintTX.rawTransaction);

    // Provide appropriate network for Etherscan link
    let etherscanUrl;
    if (network.name !== 'mainnet') {
      etherscanUrl = `https://${network.name}.etherscan.io/tx/${createReceipt.transactionHash}`;
      console.log(`NFT successfully minted on ${network.name} with hash: ${createReceipt.transactionHash}\n\nView the transaction on Etherscan: ${etherscanUrl}\n`);
    } else {
      etherscanUrl = `https://etherscan.io/tx/${createReceipt.transactionHash}`;
      console.log(`NFT successfully minted on ${network.name} with hash: ${createReceipt.transactionHash}\n\nView the transaction on Etherscan: ${etherscanUrl}\n`);
    }

    // Push the transaction URL to the array
    txUrls.push(etherscanUrl);

    // Increment the nonce for the next transaction
    nonce++;

    // Wait before the next mint
    console.log(`Allowing time for network propagation...`);
    await new Promise((resolve) => setTimeout(resolve, 22222));
  }

  // Write all the transaction URLs to the JSON file
  console.log(`Writing transaction URLs to ./src/output/mintTXs.json...\n`);
  fs.writeFileSync('./src/output/mintTXs.json', JSON.stringify(txUrls, null, 2));
};

// Don't forget to run the main function!
startMint();

And to put a definitive curtains call on our tutorial, let’s do one final recap with the full mint.js script. You can find all files involved in the making of this tutorial in the full tutorial repo.

// Process dependencies
require('dotenv').config();
require("@nomiclabs/hardhat-web3");
const fs = require('fs');
const path = require('path');

// Initialize your wallet address and private key
const address = process.env.WALLET;
const privKey = process.env.PRIVATE_KEY;

// Initialize your deployed smart contract address for the selected network
let contractAdrs;
if (network.name == 'sepolia') {
  const contractENV = process.env.SEPOLIA_CONTRACT
  contractAdrs = contractENV;
} else if (network.name == 'goerli') {
  const contractENV = process.env.GOERLI_CONTRACT;
  contractAdrs = contractENV;
} else {
  const contractENV = process.env.MAINNET_CONTRACT;
  contractAdrs = contractENV;
}

// Replace 'MyFirstMusicNFT' with your contract's name.
const contractName = 'MyFirstMusicNFT';

// Find the compiled smart contract to get the ABI
const artifactPath = path.resolve(__dirname, `../artifacts/contracts/${contractName}.sol/${contractName}.json`);
const contractArtifact = JSON.parse(fs.readFileSync(artifactPath, 'utf-8'));
const contractABI = contractArtifact.abi;

// Load metadata URLs from file
const metadataUrls = require('../src/output/metadataURLs.json');

// Create a new contract object and set interactions origin to the owner address
const contractObj = new web3.eth.Contract(contractABI, contractAdrs, {
  from: address,
});

// Define the minting function
const startMint = async () => {
  console.log(`\nAttempting to mint on ${network.name} to: ${address}...\n`);

  // Create an array to store all transaction URLs
  let txUrls = [];

  // Get the current transaction count, which will serve as the initial nonce
  let nonce = await web3.eth.getTransactionCount(address);

  // Iterate over each metadata URL to mint NFT
  for (const metadata of metadataUrls) {

    // Estimate the gas costs needed to process the transaction
    const gasCost = await contractObj.methods.safeMint(address, metadata).estimateGas((err, gas) => {
      if (!err) console.log(`Estimated gas: ${gas} for metadata: ${metadata}\n`);
      else console.error(`Error estimating gas: ${err} for metadata: ${metadata}\n`);
    });

    // Define the transaction details and sign it
    const mintTX = await web3.eth.accounts.signTransaction(
      {
        from: address,
        to: contractAdrs,
        data: contractObj.methods.safeMint(address, metadata).encodeABI(),
        gas: gasCost,
        nonce: nonce, 
      },
      privKey,
    );

    // Get transaction receipt
    const createReceipt = await web3.eth.sendSignedTransaction(mintTX.rawTransaction);

    // Provide appropriate network for Etherscan link
    let etherscanUrl;
    if (network.name !== 'mainnet') {
      etherscanUrl = `https://${network.name}.etherscan.io/tx/${createReceipt.transactionHash}`;
      console.log(`NFT successfully minted on ${network.name} with hash: ${createReceipt.transactionHash}\n\nView the transaction on Etherscan: ${etherscanUrl}\n`);
    } else {
      etherscanUrl = `https://etherscan.io/tx/${createReceipt.transactionHash}`;
      console.log(`NFT successfully minted on ${network.name} with hash: ${createReceipt.transactionHash}\n\nView the transaction on Etherscan: ${etherscanUrl}\n`);
    }

    // Push the transaction URL to the array
    txUrls.push(etherscanUrl);

    // Increment the nonce for the next transaction
    nonce++;

    // Wait before the next mint
    console.log(`Allowing time for network propagation...`);
    await new Promise((resolve) => setTimeout(resolve, 22222));
  }

  // Write all the transaction URLs to the JSON file
  console.log(`Writing transaction URLs to ./src/output/mintTXs.json...\n`);
  fs.writeFileSync('./src/output/mintTXs.json', JSON.stringify(txUrls, null, 2));
};

// Don't forget to run the main function!
startMint();

Bringing it all together

Congratulations on successfully traversing through the captivating world of minting generative music NFTs! You have now carved out a thorough process for creating, deploying, and managing your personal cache of digital music tokens.

By harnessing the potency of blockchain technology, individuals like you—whether creators or collectors—can make the most of this novel channel for creative display, secure ownership, and economic gain.

As you step forward on your journey with music NFTs, bear in mind that the scope for innovation is limitless. Fiddle with a variety of parameters and smart contract features to craft NFTs that not only encapsulate your distinct artistic flair but also provide valuable content for your patrons.

Whether you are an established music maker or an up-and-coming artist, music NFTs provide the chance to unlock fresh opportunities and revamp the way you interact with and value the auditory arts.

So don't hold back, embrace the adventure, and kickstart the process of minting your exclusive music NFTs. Broadcast your creations far and wide, and experience the revolutionizing impact of this frontier technology on the dynamic world of music.

Ready to see the generation in action? Find a collection of generative music NFTs, minted in the process of the tutorial on Goerli OpenSea.

Here are some of the results:

Onwards to a symphony of success, happy minting!

About the author

Petar Stoykov

🔥 Senior Copywriter | Chainstack
✍️ Writes on Ethereum, NFTs, and underlying technology
🎯 I BUIDL tutorials insightful so your dev experience can be delightful.
Petar Stoykov | GitHub Petar Stoykov | Twitter Petar Stoykov | LinkedIN