Sound Manager API v1.1

Updated: Feb 16 2025

Update Notes from v1.0 to v1.1:

  • UI Upgrade: All menus (Main, Configured Sounds, Jukebox, and Change Bar) are now grouped into a single contained chat output.
  • GM Whisper: Every output message is now whispered to the GM.
  • Bar Toggle Added: You can now toggle which token bar (bar1, bar2, or bar3) is used for triggering sounds.

Sound Manager API – How to Use

The Sound Manager API lets you manage sound effects in your Roll20 game by assigning sounds from your Roll20 jukebox to three categories:

  • Damage (dmg)
  • Heal (heal)
  • Death (death)

The API plays a random sound from the appropriate category when a token’s hit points change. It also stores your sound configuration persistently in a handout so your settings are preserved across API restarts.


Installation

  1. Copy the API Code:
    Open your Roll20 game, go to the API Scripts section, create a new script, and paste the full API code into the editor. Save the script.

  2. Persistent Storage:
    On startup, the API will look for (or create) a handout named "SoundManagerConfig". This handout stores your sound configuration (sound names and their categories) and the selected bar in JSON format.
    Note: Do not delete or modify this handout unless you want to reset your configuration.


Using the API

Main Menu

Command: !sound-menu
Type this command (as GM) to display the main menu. The menu includes:

  • List Configured Sounds
    Shows all sounds you have added along with options to preview, change their category, or remove them.
  • Browse Jukebox
    Lists all available jukebox tracks so you can add them.
  • Add Sound Manually
    Allows you to type in the exact name of a sound to add it manually.
  • Change Bar
    Opens a menu where you can choose which token bar (bar1, bar2, or bar3) is used for triggering sound effects.

Bar Toggle

By default, the API monitors bar1. To change this:

  1. Open the Main Menu and click Change Bar.
  2. In the Change Bar menu, click one of the buttons for Bar 1, Bar 2, or Bar 3.
  3. The selected bar is stored persistently, and token HP triggers will now use the chosen bar.

Adding Sounds

Manually Adding a Sound:

  • Command: !set-sound <Sound Name>
    Example:
    !set-sound Thunder Claps
    This adds "Thunder Claps" to your configuration with the default category Damage (dmg).

Adding from the Jukebox:

  • Command: !jukebox-sounds
    This displays a list of all available jukebox tracks.
    Next to each track, there is an [Add] link.
    Clicking that link adds the track to your configuration (with the default category Damage).

Listing & Managing Sounds

Listing Configured Sounds:

  • Command: !list-sounds
    This displays a list of all configured sounds. For each sound, you will see:
    • Sound Name: Clickable (as a button) to preview/play the sound.
    • Current Category: Shown next to the name.
    • Category Change Buttons: Three buttons labeled Damage, Heal, and Death to change the category.
    • Remove Button: A Remove link to delete the sound.
    • Navigation: At the bottom of the list, you will see navigation buttons: Main, Jukebox, and Refresh (to reload the list).

Changing a Sound’s Category Directly:

  • Command: !set-sound-cat <Sound Name> <Category>
    Valid categories are:
    • dmg (Damage)
    • heal (Heal)
    • death (Death)
      Example:
      !set-sound-cat Thunder Claps heal
      This sets "Thunder Claps" to the Heal category.

Removing a Sound:

  • Command: !remove-sound <Sound Name>
    Example:
    !remove-sound Thunder Claps

Previewing a Sound:

  • Command: !play-sound <Sound Name>
    Example:
    !play-sound Thunder Claps
    This plays the specified sound for preview.

Jukebox Browser

Browsing the Jukebox:

  • Command: !jukebox-sounds
    This command displays a list of all available jukebox tracks. For each track:
    • The track name is rendered as a button that plays the sound directly.
    • Next to each track is an [Add] link to add the track to your configuration (default category Damage).
    • Navigation buttons at the bottom let you go to the Main Menu, view the Configured Sounds list, or refresh the list.

Token HP Triggers

The API automatically monitors changes to tokens’ HP using the value in the configured bar. Depending on how the HP changes:

  • Taking Damage:
    • If HP falls to 0 or below, a random Death sound is played.
    • If HP decreases (but stays above 0), a random Damage sound is played.
  • Healing:
    • If HP increases, a random Heal sound is played.

These triggers work in tandem with damage application commands (like !apply-damage or other systems that modify token HP).


Tips & Reminders

  • Persistent Configuration:
    Your configuration is saved in the handout "SoundManagerConfig". Do not delete this handout unless you wish to reset your settings.
  • Case Sensitivity:
    When managing sounds, the API now performs case-insensitive matching.
  • Testing:
    Use the preview command (!play-sound) to test sounds before relying on them in gameplay.

Summary of Commands

  • Main Menu: !sound-menu
  • Add Sound Manually: !set-sound <Sound Name>
  • List Configured Sounds: !list-sounds
  • Change Sound Category:
    Either by clicking the buttons in the list or using !set-sound-cat <Sound Name> <dmg/heal/death>
  • Remove Sound: !remove-sound <Sound Name>
  • Preview Sound: !play-sound <Sound Name>
  • Browse Jukebox: !jukebox-sounds
  • Add from Jukebox: Click the [Add] link next to a track
  • Change Bar:
    Access via the Main Menu → Change Bar; then select Bar 1, Bar 2, or Bar 3 using !set-bar <number>


/* 
Sound Manager API v1.1
Updated: Feb 16 2025
Roll20 Profile: https://app.roll20.net/users/335573/surok

Update Notes from v1.0 to v1.1:
- UI Upgrade: All menus (Main, Configured Sounds, Jukebox, and Change Bar) are now contained in a single chat output with an AURA-inspired blue gradient background.
- GM Whisper: All output messages are now whispered to the GM.
- Bar Toggle: Added a Change Bar menu to allow toggling between bar1, bar2, and bar3 for token HP triggers.
- Storage Overhaul: Sounds are now stored in an array (each with a normalized name, display name, and category). Updating a sound’s category simply changes its stored category rather than creating duplicate keys.
*/

on('ready', () => {
    const configHandoutName = "SoundManagerConfig";

    // Normalization function: trim, lowercase, and remove a trailing valid category word.
    const normalizeSoundName = (name) => {
        let tokens = name.trim().toLowerCase().split(/\s+/);
        if(tokens.length > 1 && ["dmg", "heal", "death"].includes(tokens[tokens.length-1])){
            tokens.pop();
        }
        return tokens.join(" ");
    };

    // ----------------------------
    // Configuration Persistence
    // ----------------------------
    // state.configuredSounds is now an array of objects: { norm, display, category }
    const loadConfig = () => {
        let handout = findObjs({ type: 'handout', name: configHandoutName })[0];
        if(handout){
            handout.get('notes', function(notes){
                if(!notes || notes.trim() === ""){
                    state.configuredSounds = [];
                    state.configuredBar = "bar1";
                    log("Handout is empty; resetting configuration.");
                    return;
                }
                try{
                    let config = JSON.parse(notes);
                    if(typeof config === "object" && config.sounds !== undefined && Array.isArray(config.sounds)){
                        state.configuredSounds = config.sounds;
                        state.configuredBar = config.bar || "bar1";
                    } else {
                        state.configuredSounds = [];
                        state.configuredBar = "bar1";
                    }
                    log("Loaded configuration: " + JSON.stringify(config));
                } catch(e){
                    state.configuredSounds = [];
                    state.configuredBar = "bar1";
                    log("Error parsing configuration; starting with empty configuration and default bar1.");
                }
            });
        } else {
            state.configuredSounds = [];
            state.configuredBar = "bar1";
            createObj('handout', { name: configHandoutName, notes: JSON.stringify({sounds: state.configuredSounds, bar: state.configuredBar}) });
            log("No configuration handout found. Created new one with default bar1.");
        }
    };

    const updateConfig = () => {
        let config = {
            sounds: state.configuredSounds,
            bar: state.configuredBar || "bar1"
        };
        let handout = findObjs({ type: 'handout', name: configHandoutName })[0];
        if(handout){
            handout.set('notes', JSON.stringify(config));
        } else {
            createObj('handout', { name: configHandoutName, notes: JSON.stringify(config) });
        }
    };

    // Optional reset command (if needed)
    on('chat:message', (msg) => {
        if(msg.type === 'api' && msg.content === '!reset-sound-config'){
            state.configuredSounds = [];
            state.configuredBar = "bar1";
            updateConfig();
            sendChat('Sound Manager', `/w gm Configuration reset.`);
        }
    });

    const ensureConfig = () => {
        let handout = findObjs({ type: 'handout', name: configHandoutName })[0];
        if(!handout){
            state.configuredSounds = [];
            state.configuredBar = "bar1";
        }
    };

    loadConfig();

    // ----------------------------
    // Utility Functions
    // ----------------------------
    const playSound = (trackname, action) => {
        let track = findObjs({ type: 'jukeboxtrack', title: trackname })[0];
        if(track){
            track.set('playing', false);
            track.set('softstop', false);
            if(action === 'play'){
                track.set('playing', true);
            }
        } else {
            sendChat('Sound Manager', `/w gm No Track Found for: ${trackname}`);
            log("No track found: " + trackname);
        }
    };

    const playRandomSoundFromCategory = (category) => {
        let matches = state.configuredSounds.filter(entry => entry.category === category);
        if(matches.length === 0){
            sendChat('Sound Manager', `/w gm No ${category} sounds configured.`);
            return;
        }
        let randomEntry = matches[Math.floor(Math.random() * matches.length)];
        playSound(randomEntry.display, 'play');
    };

    // ----------------------------
    // UI Styling Variables (AURA-Inspired)
    // ----------------------------
    const containerStyle = "border: 2px solid black; border-radius: 4px; box-shadow: 1px 1px 1px #707070; text-align: center; vertical-align: middle; padding: 3px 0px; margin: 0px auto; color: #000; background-image: -webkit-linear-gradient(-45deg, #a7c7dc 0%, #85b2d3 100%);";
    const menuButtonStyle = 'display: block; text-decoration: none; color: white; background-color: #6FAEC7; padding: 5px; border-radius: 4px; box-shadow: 1px 1px 1px #707070; margin-bottom: 5px;';
    const listStyle = 'list-style: none; padding: 0; margin: 0; text-align: center;';
    const jukeboxListStyle = 'list-style: none; padding: 0; margin: 0; text-align: left;';
    const headerStyle = 'text-align: center; margin: 0 0 10px;';
    const smallButtonStyle = 'padding-top: 1px; text-align: center; font-size: 9pt; width: 48px; height: 14px; border: 1px solid black; margin: 1px; border-radius: 4px; box-shadow: 1px 1px 1px #707070; color: white; text-decoration: none; display: inline-block;';
    const playButtonStyle = 'padding: 2px 4px; text-align: center; font-size: 9pt; border: 1px solid black; margin: 1px; border-radius: 4px; box-shadow: 1px 1px 1px #707070; color: white; text-decoration: none; display: inline-block; background-color: #6FAEC7;';
    const damageButtonStyle = smallButtonStyle + ' background-color: #B22222;';
    const healButtonStyle = smallButtonStyle + ' background-color: #32CD32;';
    const deathButtonStyle = smallButtonStyle + ' background-color: #000000;';

    // Navigation blocks
    const navForSounds = '<div style="text-align: center; margin-top: 10px;">'
      + '<a href="!sound-menu" style="' + smallButtonStyle + '">Main</a>'
      + '<a href="!jukebox-sounds" style="' + smallButtonStyle + '">Jukebox</a>'
      + '<a href="!list-sounds" style="' + smallButtonStyle + '">Refresh</a>'
      + '</div>';
    const navForJukebox = '<div style="text-align: center; margin-top: 10px;">'
      + '<a href="!sound-menu" style="' + smallButtonStyle + '">Main</a>'
      + '<a href="!list-sounds" style="' + smallButtonStyle + '">Sounds</a>'
      + '<a href="!jukebox-sounds" style="' + smallButtonStyle + '">Refresh</a>'
      + '</div>';
    const navForChangeBar = '<div style="text-align: center; margin-top: 10px;">'
      + '<a href="!sound-menu" style="' + smallButtonStyle + '">Main</a>'
      + '</div>';

    // ----------------------------
    // Main Menu (Whispered to GM)
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content === '!sound-menu'){
            let menu = `<div style="${containerStyle}">`;
            menu += `<h3 style="${headerStyle}">Sound Manager Menu</h3>`;
            menu += `<ul style="${listStyle}">`;
            menu += `<li><a href="!list-sounds" style="${menuButtonStyle}">List Configured Sounds</a></li>`;
            menu += `<li><a href="!jukebox-sounds" style="${menuButtonStyle}">Browse Jukebox</a></li>`;
            menu += `<li><a href="!set-sound ?{Sound Name}" style="${menuButtonStyle}">Add Sound Manually</a></li>`;
            menu += `<li><a href="!change-bar-menu" style="${menuButtonStyle}">Change Bar</a></li>`;
            menu += `</ul>`;
            menu += `<p style="font-style: italic; margin-top: 10px;">New sounds default to the Damage category.<br>Currently monitoring: ${state.configuredBar}</p>`;
            menu += `</div>`;
            sendChat('Sound Menu', `/w gm ${menu}`);
        }
    });

    // ----------------------------
    // Change Bar Menu (Whispered to GM)
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content === '!change-bar-menu'){
            let output = `<div style="${containerStyle}">`;
            output += `<h3 style="${headerStyle}">Change Bar</h3>`;
            output += `<p>Select which bar to monitor:</p>`;
            output += `<a href="!set-bar 1" style="${smallButtonStyle}">Bar 1</a> `;
            output += `<a href="!set-bar 2" style="${smallButtonStyle}">Bar 2</a> `;
            output += `<a href="!set-bar 3" style="${smallButtonStyle}">Bar 3</a>`;
            output += `<br>` + navForChangeBar;
            output += `</div>`;
            sendChat('Change Bar', `/w gm ${output}`);
        }
    });

    // ----------------------------
    // Command to Set Bar: !set-bar <number>
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content.indexOf('!set-bar') === 0){
            let args = msg.content.split(/\s+/);
            if(args.length < 2){
                sendChat('Sound Manager', `/w gm Usage: !set-bar <1|2|3>`);
                return;
            }
            let barNum = args[1].trim();
            if(!["1","2","3"].includes(barNum)){
                sendChat('Sound Manager', `/w gm Invalid bar. Choose 1, 2, or 3.`);
                return;
            }
            state.configuredBar = "bar" + barNum;
            updateConfig();
            sendChat('Sound Manager', `/w gm Now monitoring ${state.configuredBar}.`);
        }
    });

    // ----------------------------
    // Manual Add/Update Command: !set-sound
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content.indexOf('!set-sound') === 0){
            let args = msg.content.split(/\s+/);
            if(args.length < 2){
                sendChat('Sound Manager', `/w gm Usage: !set-sound <Sound Name>`);
                return;
            }
            let soundName = args.slice(1).join(" ").trim();
            let norm = normalizeSoundName(soundName);
            // If already exists, do not add duplicate.
            let exists = state.configuredSounds.find(entry => entry.norm === norm);
            if(!exists){
                state.configuredSounds.push({ norm: norm, display: soundName, category: "dmg" });
                updateConfig();
                sendChat('Sound Manager', `/w gm Sound "<b>${soundName}</b>" added with default category Damage.`);
            } else {
                sendChat('Sound Manager', `/w gm Sound "<b>${exists.display}</b>" is already configured.`);
            }
        }
    });

    // ----------------------------
    // Remove Command: !remove-sound
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content.indexOf('!remove-sound') === 0){
            let target = msg.content.replace('!remove-sound', '').trim().toLowerCase();
            let origLength = state.configuredSounds.length;
            state.configuredSounds = state.configuredSounds.filter(entry => entry.norm !== target);
            if(state.configuredSounds.length < origLength){
                updateConfig();
                sendChat('Sound Manager', `/w gm Sound "<b>${target}</b>" removed.`);
            } else {
                sendChat('Sound Manager', `/w gm Sound "<b>${target}</b>" is not configured.`);
            }
        }
    });

    // ----------------------------
    // Change Sound Category Command: !set-sound-cat
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content.indexOf('!set-sound-cat') === 0){
            let parts = msg.content.split(/\s+/);
            if(parts.length < 3){
                sendChat('Sound Manager', `/w gm Usage: !set-sound-cat <Sound Name> <Category (dmg/heal/death)>`);
                return;
            }
            let category = parts[parts.length - 1].toLowerCase();
            let soundName = parts.slice(1, parts.length - 1).join(" ").trim();
            let norm = normalizeSoundName(soundName);
            if(!["dmg","heal","death"].includes(category)){
                sendChat('Sound Manager', `/w gm Invalid category. Use dmg, heal, or death.`);
                return;
            }
            let updated = false;
            state.configuredSounds.forEach(entry => {
                if(entry.norm === norm){
                    entry.category = category;
                    updated = true;
                }
            });
            if(updated){
                updateConfig();
                sendChat('Sound Manager', `/w gm Sound "<b>${soundName}</b>" updated to category ${category}.`);
            } else {
                sendChat('Sound Manager', `/w gm Sound "<b>${soundName}</b>" is not configured.`);
            }
        }
    });

    // ----------------------------
    // Preview Command: !play-sound (for configured sounds)
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content.indexOf('!play-sound') === 0){
            let target = msg.content.replace('!play-sound', '').trim().toLowerCase();
            let found = state.configuredSounds.find(entry => entry.norm === target);
            if(found){
                playSound(found.display, 'play');
                sendChat('Sound Manager', `/w gm Previewing sound: <b>${found.display}</b>`);
            } else {
                sendChat('Sound Manager', `/w gm Sound "<b>${target}</b>" is not configured.`);
            }
        }
    });

    // ----------------------------
    // Configured Sounds Menu (Single Output) with Navigation, Whispered to GM
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content === '!list-sounds'){
            let output = `<div style="${containerStyle}">`;
            output += `<h3 style="${headerStyle}">Configured Sounds</h3>`;
            output += `<ul style="${listStyle}">`;
            state.configuredSounds.forEach(entry => {
                output += `<li style="margin-bottom: 5px;">`;
                output += `<a href="!play-sound ${entry.display}" style="${playButtonStyle}">${entry.display}</a> (Category: ${entry.category})<br>`;
                output += `<a href="!set-sound-cat ${entry.display} dmg" style="${damageButtonStyle}">Damage</a> `;
                output += `<a href="!set-sound-cat ${entry.display} heal" style="${healButtonStyle}">Heal</a> `;
                output += `<a href="!set-sound-cat ${entry.display} death" style="${deathButtonStyle}">Death</a> `;
                output += `<a href="!remove-sound ${entry.display}" style="${smallButtonStyle}">Remove</a>`;
                output += `</li>`;
            });
            output += `</ul>`;
            output += navForSounds;
            output += `</div>`;
            sendChat('Sound Manager', `/w gm ${output}`);
        }
    });

    // ----------------------------
    // Jukebox Browser Menu (Single Output) with Navigation, Whispered to GM
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content === '!jukebox-sounds'){
            let tracks = findObjs({ type: 'jukeboxtrack' });
            if(!tracks || tracks.length === 0){
                sendChat('Sound Manager', `/w gm No jukebox tracks found.`);
                return;
            }
            let output = `<div style="${containerStyle}">`;
            output += `<h3 style="${headerStyle}">Available Jukebox Sounds</h3>`;
            output += `<ul style="${jukeboxListStyle}">`;
            tracks.forEach(track => {
                let title = track.get('title');
                let encodedTitle = encodeURIComponent(title);
                output += `<li style="margin-bottom: 5px;">`;
                output += `<a href="!play-jukebox ${title}" style="${playButtonStyle}">${title}</a> `;
                output += `<a href="!add-from-jukebox ${encodedTitle}" style="${smallButtonStyle}">Add</a>`;
                output += `</li>`;
            });
            output += `</ul>`;
            output += navForJukebox;
            output += `</div>`;
            sendChat('Sound Manager', `/w gm ${output}`);
        }
    });

    // ----------------------------
    // Command to Add From Jukebox: !add-from-jukebox
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content.indexOf('!add-from-jukebox') === 0){
            let encodedName = msg.content.replace('!add-from-jukebox', '').trim();
            if(!encodedName){
                sendChat('Sound Manager', `/w gm No track specified.`);
                return;
            }
            let trackName = decodeURIComponent(encodedName);
            let norm = normalizeSoundName(trackName);
            let exists = state.configuredSounds.find(entry => entry.norm === norm);
            if(exists){
                sendChat('Sound Manager', `/w gm Sound "<b>${exists.display}</b>" is already configured.`);
            } else {
                state.configuredSounds.push({ norm: norm, display: trackName, category: "dmg" });
                updateConfig();
                sendChat('Sound Manager', `/w gm Sound "<b>${trackName}</b>" added with default category Damage.`);
            }
        }
    });

    // ----------------------------
    // New Command: !play-jukebox to play a track directly from the jukebox list
    // ----------------------------
    on('chat:message', (msg) => {
        ensureConfig();
        if(msg.type !== 'api') return;
        if(msg.content.indexOf('!play-jukebox') === 0){
            let trackName = msg.content.replace('!play-jukebox', '').trim();
            if(!trackName){
                sendChat('Sound Manager', `/w gm Usage: !play-jukebox <Track Name>`);
                return;
            }
            playSound(trackName, 'play');
            sendChat('Sound Manager', `/w gm Previewing jukebox track: <b>${trackName}</b>`);
        }
    });

    // ----------------------------
    // Token HP Update Triggers (Generic Listener)
    // ----------------------------
    on("change:graphic", function(obj, prev){
        const barUsed = state.configuredBar || "bar1";
        if(obj.get(`${barUsed}_value`) !== prev[`${barUsed}_value`]){
            const currentHp = parseInt(obj.get(`${barUsed}_value`), 10);
            const previousHp = parseInt(prev[`${barUsed}_value`], 10);
            if(currentHp < previousHp){
                if(currentHp <= 0){
                    playRandomSoundFromCategory("death");
                } else {
                    playRandomSoundFromCategory("dmg");
                }
            } else if(currentHp > previousHp){
                playRandomSoundFromCategory("heal");
            }
        }
    });

    on('chat:message', (msg) => {
        if(msg.type === 'api' && /^!token-mod --set/.test(msg.content)){
            playRandomSoundFromCategory("dmg");
        }
    });

    on('chat:message', (msg) => {
        if(msg.type === 'api' && msg.content.startsWith('!apply-damage')){
            const args = msg.content.split(' ');
            if(args.includes('--dmg')){
                if(!msg.selected || msg.selected.length === 0){
                    sendChat('Sound Manager', `/w gm Please select a token to apply damage.`);
                    return;
                }
                msg.selected.forEach(obj => {
                    let token = getObj('graphic', obj._id);
                    if(token){
                        const currentHp = parseInt(token.get("bar1_value"), 10);
                        if(currentHp <= 0){
                            playRandomSoundFromCategory("death");
                        } else {
                            playRandomSoundFromCategory("dmg");
                        }
                    }
                });
            }
        }
    });
});