A/B Audio Player

Is this new web version coming natively for Keyboard Maestro as well? It’s just brilliant stuff here, mate.

As it stands, the web version has the most functionality, but the KM version has the convenience of loading the currently selected files, so that's what I use.

The Electron app version works the same as the web app, but its bloated by the runtime and this simple html app ends up at ~250mb. I've given up on Electron for now.

I’m impressed with the browser version because there’s no lag when I switch between the audio. No lag is crucial for me.

It would also be great if both audios could start from the beginning when I use the Command + Space keystroke.

I might create a new macro so that when I execute it, Safari opens and goes to the link you provided, and I can drag my audio that way. I’d rather have no lag than the convenience of executing the macro from my Mac.

Interesting. I experience zero lag with the KM version.

That is exactly what happens here. Is it not working for you?

It is very minuscule, like 0.01 seconds, a little hiccup, just like how logic does when you switch from one audio file to another. I did not experience that at all with the browser version.

When I use the command and then press the space key, only the selected file goes back.

Maybe it’s not a good picture because before I took the screenshot, I selected the other audio, but you get my point :upside_down_face:

That's bizarre. It works in Brave, Safari and Chrome here without issue.

Can you consider adding a button for this task? Maybe the command space can be used for it?

I'm not sure what you're asking for.

Cmd+Space already resets the playback position of all players. If it isn't working for you, then triggering the same function with a mouse click won't get a different result.

Ok, great news! I opened a new window, and now the command space works. Could you please implement a button so users can choose the file from the browser instead of dragging the files? Thanks

Ok I'll try to get that together when I have some free time. :+1:t3:

Done. The web version now has a file load button to the left of each player. Any of these can be used to load multiple files at once or to replace a single file.

2 Likes

I've managed to get the waveform view working in KM, but I'm having a nightmare trying to get the selected audio files to pre-load. If anyone fancies having a go...?

Code
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>A/B My Mix</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/wavesurfer.js/6.6.3/wavesurfer.min.js"></script>
    <script src="https://unpkg.com/wavesurfer.js@6.6.3/dist/plugin/wavesurfer.regions.min.js"></script>
    <style>
        body {
            font-family: Arial, sans-serif;
            padding: 20px;
            background-color: rgba(50, 50, 80, 1);
            color: white;
            text-align: center;
        }

        .audio-container-wrapper {
            display: flex;
            flex-direction: column;
            align-items: center;
            margin-bottom: 20px;
        }

        .audio-container {
            padding: 10px;
            height: auto;
            box-sizing: border-box;
            display: flex;
            flex-direction: column;
            align-items: center;
            border: 2px solid transparent;
            width: 100%;
            max-width: 500px;
            margin: 0 auto 20px auto;
            position: relative;
            transition: all 0.3s ease;
        }

        .filename {
            font-size: 14px;
            margin-bottom: 5px;
            white-space: nowrap;
            color: white;
        }

        .waveform-container {
            width: 100%;
            height: 35px;
            background-color: #2c3e50;
            position: relative;
        }

        .placeholder-text {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: #95a5a6;
            font-style: italic;
        }

        .volume-slider {
            width: 100%;
            max-width: 480px;
            margin-top: 10px;
        }

        .highlight {
            border-color: #45a049;
            box-shadow: 0 0 10px #45a049;
        }

        .play-pause-button, .rewind-button, .forward-button, .ab-button, .loop-button {
            color: white;
            padding: 10px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
            background: none;
        }

        .ab-button {
            background-color: #45a049;
        }

        .button-container {
            display: flex;
            justify-content: center;
            margin-top: 20px;
            align-items: center;
        }

        .ab-button-container {
            display: flex;
            justify-content: center;
            margin-top: 10px;
        }

        .add-button-container {
            display: flex;
            justify-content: center;
            margin-top: 20px;
        }

        .add-button {
            color: white;
            padding: 10px;
            border: none;
            border-radius: 50%;
            cursor: pointer;
            font-size: 24px;
            background: none;
        }

        .intro-text {
            font-size: 16px;
            text-align: center;
            margin-bottom: 20px;
        }

        .time-display {
            font-size: 12px;
            margin-top: 5px;
            color: #95a5a6;
        }

        .loop-button {
            background-color: #3498db;
            margin-bottom: 10px;
        }

        .loop-active {
            background-color: #e74c3c;
        }
    </style>
</head>
<body>
    <div class="intro-text">
        Drag and drop one or more audio files onto the players.<br>
        Hover over the buttons to show keyboard controls.
    </div>
    <div id="audioPlayersContainer" class="audio-container-wrapper"></div>
    <div class="add-button-container">
        <button id="addPlayerButton" class="add-button" title="Add new audio player">
            +
        </button>
    </div>
    <div class="button-container">
<button id="loopButton" class="loop-button" title="L to set loop points or reset">Loop In</button>    </div>
    <div class="button-container">
        <button id="rewind10Button" class="rewind-button" title="←">
            ←10
        </button>
        <button id="rewind3Button" class="rewind-button" title="⇧←">
            ←3
        </button>
        <button id="playPauseButton" class="play-pause-button" title="Space: play/pause... ⌘Space: play from start">
            <svg id="playPauseIcon" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                <polygon id="playIcon" points="6,4 20,12 6,20" fill="#45a049" style="display: none;" />
                <rect id="pauseIcon" x="6" y="4" width="4" height="16" fill="#d9534f" />
                <rect id="pauseIcon2" x="14" y="4" width="4" height="16" fill="#d9534f" />
            </svg>
        </button>
        <button id="forward3Button" class="forward-button" title="⇧→">
            3→
        </button>
        <button id="forward10Button" class="forward-button" title="→">
            10→
        </button>
    </div>
    <div class="ab-button-container">
        <button id="abButton" class="ab-button" title="/ to cycle players or hit 1, 2, 3... or A, B, C...">A</button>
    </div>

    <script>
        const audioPlayers = [];
        let currentPlayerIndex = 0;
        let isPlaying = false;
        let abButtonMode = 'letter';
        let loopState = 'in';
        let loopStart = null;
        let loopEnd = null;
        let loopRegion = null;

        document.addEventListener('keydown', handleKeyboardEvent);
        document.addEventListener('DOMContentLoaded', preloadAudioPlayers);

function handleKeyboardEvent(event) {
    if (event.key === '/' || event.keyCode === 191) {
        console.log('Slash key detected');
        if (event.shiftKey) {
            console.log('Shift+/ detected, calling switchAudioReverse');
            switchAudioReverse();
        } else {
            console.log('/ detected, calling switchAudio');
            switchAudio();
        }
        event.preventDefault();
        return;
    }

    if (event.shiftKey && event.key === 'ArrowLeft') {
        rewind(3);
    } else if (event.shiftKey && event.key === 'ArrowRight') {
        forward(3);
    } else if (event.metaKey && event.key === ' ') {
        resetAndPlayFromStart();
        event.preventDefault();
    } else if (event.metaKey && event.key === 'w') {
        stopAndClose();
        event.preventDefault();
    } else {
        switch (event.key) {
            case ' ':
                togglePlayPause();
                event.preventDefault();
                break;
                            case '/':
                if (event.shiftKey) {
                    switchAudioReverse();
                } else {
                    switchAudio();
                }
                event.preventDefault();
                break;
            case 'ArrowLeft':
                rewind(10);
                event.preventDefault();
                break;
            case 'ArrowRight':
                forward(10);
                event.preventDefault();
                break;
            case 'ArrowUp':
                increaseVolume();
                event.preventDefault();
                break;
            case 'ArrowDown':
                decreaseVolume();
                event.preventDefault();
                break;
            case 'Escape':
                stopAndClose();
                event.preventDefault();
                break;
            case 'l':
            case 'L':
                toggleLoop();
                event.preventDefault();
                break;
            case '1':
            case '2':
            case '3':
            case '4':
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
                setCurrentPlayer(parseInt(event.key) - 1, event.key);
                event.preventDefault();
                break;
            case '0':
                setCurrentPlayer(9, event.key);
                event.preventDefault();
                break;
        }
    }
}
function setCurrentPlayer(index, keyPressed) {
    if (index < audioPlayers.length) {
        currentPlayerIndex = index;
        audioPlayers.forEach((player, i) => {
            player.wavesurfer.setMute(i !== currentPlayerIndex);
            player.container.classList.toggle('highlight', i === currentPlayerIndex);
            if (i === currentPlayerIndex) {
                player.container.style.transform = 'scale(1.05)';
            } else {
                player.container.style.transform = 'scale(1)';
            }
        });
        if (keyPressed !== undefined) {
            abButton.textContent = keyPressed;
        } else {
            abButton.textContent = (currentPlayerIndex + 1).toString();
        }
        playAudio();
    }
}
        function createAudioPlayer(id, index) {
            const playerContainer = document.createElement('div');
            playerContainer.classList.add('audio-container');
            if (index === 0) playerContainer.classList.add('highlight');

            const filenameDiv = document.createElement('div');
            filenameDiv.classList.add('filename');
            filenameDiv.id = `filename${id}`;

            const waveformContainer = document.createElement('div');
            waveformContainer.classList.add('waveform-container');
            waveformContainer.id = `waveform${id}`;

            const placeholderText = document.createElement('div');
            placeholderText.classList.add('placeholder-text');
            placeholderText.textContent = "Drop some audio onto me";
            waveformContainer.appendChild(placeholderText);

            const timeDisplay = document.createElement('div');
            timeDisplay.classList.add('time-display');
            timeDisplay.id = `timeDisplay${id}`;
            timeDisplay.textContent = '0:00 / 0:00';

            const volumeSlider = document.createElement('input');
            volumeSlider.type = 'range';
            volumeSlider.classList.add('volume-slider');
            volumeSlider.id = `volumeSlider${id}`;
            volumeSlider.min = '0';
            volumeSlider.max = '1';
            volumeSlider.step = '0.1';
            volumeSlider.value = '1';
            volumeSlider.style.width = '100%';
            volumeSlider.style.maxWidth = '600px';

            playerContainer.appendChild(filenameDiv);
            playerContainer.appendChild(waveformContainer);
            playerContainer.appendChild(timeDisplay);
            playerContainer.appendChild(volumeSlider);

            filenameDiv.addEventListener('click', function() {
                setCurrentPlayer(index);
            });

            playerContainer.addEventListener('drop', function(e) {
                e.preventDefault();
                const files = e.dataTransfer.files;
                pauseAndResetPlayers();
                if (files.length === 1) {
                    loadAudioFile(files[0], index);
                    filenameDiv.textContent = files[0].name;
                } else {
                    handleMultipleFilesDrop(files);
                }
                updatePlayPauseButton();
            });
    
            playerContainer.addEventListener('dragover', function(e) {
                e.preventDefault();
            });

            volumeSlider.addEventListener('input', adjustVolume);

            document.getElementById('audioPlayersContainer').appendChild(playerContainer);

            const wavesurfer = WaveSurfer.create({
                container: waveformContainer,
                waveColor: '#4a9eff',
                progressColor: '#1e90ff',
                cursorColor: '#ff0000',
                cursorWidth: 2,
                barWidth: 2,
                barRadius: 3,
                height: 35,
                barGap: 2,
                plugins: [
                    WaveSurfer.regions.create({
                        regions: [],
                        dragSelection: true
                    })
                ]
            });

            wavesurfer.on('ready', function() {
                placeholderText.remove();
                updateTimeDisplay(wavesurfer, timeDisplay);
            });

            wavesurfer.on('audioprocess', function() {
                updateTimeDisplay(wavesurfer, timeDisplay);
                if (loopEnd !== null && wavesurfer.getCurrentTime() >= loopEnd) {
                    wavesurfer.seekTo(loopStart / wavesurfer.getDuration());
                }
            });

            audioPlayers.push({ wavesurfer, slider: volumeSlider, container: playerContainer, filename: filenameDiv, timeDisplay });
        }

        function handleMultipleFilesDrop(files) {
            const fileArray = Array.from(files);
            const filesToProcess = fileArray.slice(0, 10); // Limit to 10 files

            filesToProcess.forEach((file, index) => {
                if (file.type.startsWith('audio/')) {
                    if (index >= audioPlayers.length) {
                        const id = String.fromCharCode(65 + index);
                        createAudioPlayer(id, index);
                    }
                    loadAudioFile(file, index);
                    audioPlayers[index].filename.textContent = file.name;
                        setTimeout(kmResize, 100);
                }
            });

            // Update the AB button
            abButton.disabled = audioPlayers.length > 1 ? false : true;
            abButton.textContent = abButtonMode === 'letter' ? 'A' : '1';
            currentPlayerIndex = 0;
            setCurrentPlayer(currentPlayerIndex);
        }

        function loadAudioFile(file, index) {
            const player = audioPlayers[index];
            const fileURL = URL.createObjectURL(file);
            player.wavesurfer.load(fileURL);
            player.wavesurfer.on('ready', function() {
                player.wavesurfer.seekTo(0);
                player.wavesurfer.setMute(index !== currentPlayerIndex);
                if (isPlaying) {
                    const currentTime = audioPlayers[currentPlayerIndex].wavesurfer.getCurrentTime();
                    player.wavesurfer.setCurrentTime(currentTime);
                    player.wavesurfer.play();
                }
                updateTimeDisplay(player.wavesurfer, player.timeDisplay);
                if (loopRegion) {
                    addLoopRegion(player.wavesurfer);
                }
                    setTimeout(kmResize, 100);
            });
        }

        function toggleLoop() {
            const loopButton = document.getElementById('loopButton');
            const currentPlayer = audioPlayers[currentPlayerIndex].wavesurfer;

            if (loopState === 'in') {
                loopStart = currentPlayer.getCurrentTime();
loopButton.textContent = 'Loop Out';
                loopState = 'out';
            } else if (loopState === 'out') {
                loopEnd = currentPlayer.getCurrentTime();
                if (loopEnd > loopStart) {
                    loopRegion = {
                        start: loopStart,
                        end: loopEnd,
                        loop: true,
                        color: 'rgba(255, 255, 255, 0.3)'
                    };
                    audioPlayers.forEach(player => addLoopRegion(player.wavesurfer));
                    loopButton.textContent = 'Reset Loop';
                    loopState = 'active';
                } else {
                    // If end is before start, reset loop
                    loopStart = null;
                    loopEnd = null;
                    loopButton.textContent = 'Loop In';
                    loopState = 'in';
                }
            } else {
                // Reset loop
                audioPlayers.forEach(player => player.wavesurfer.clearRegions());
                loopStart = null;
                loopEnd = null;
                loopRegion = null;
                loopButton.textContent = 'Loop In';
                loopState = 'in';
            }
        }

        function addLoopRegion(wavesurfer) {
            wavesurfer.clearRegions();
            wavesurfer.addRegion(loopRegion);
        }

        function updateTimeDisplay(wavesurfer, timeDisplay) {
            const currentTime = formatTime(wavesurfer.getCurrentTime());
            const totalDuration = formatTime(wavesurfer.getDuration());
            timeDisplay.textContent = `${currentTime} / ${totalDuration}`;
        }

        function formatTime(timeInSeconds) {
            const minutes = Math.floor(timeInSeconds / 60);
            const seconds = Math.floor(timeInSeconds % 60);
            return `${minutes}:${seconds.toString().padStart(2, '0')}`;
        }

        function resetAndPlayFromStart() {
            audioPlayers.forEach(player => {
                player.wavesurfer.seekTo(0);
            });
            playAllPlayers();
        }

        function togglePlayPause() {
            if (isPlaying) {
                audioPlayers.forEach(player => player.wavesurfer.pause());
                isPlaying = false;
            } else {
                const currentTime = audioPlayers[currentPlayerIndex].wavesurfer.getCurrentTime();
                audioPlayers.forEach(player => {
                    player.wavesurfer.setCurrentTime(currentTime);
                    player.wavesurfer.play();
                });
                isPlaying = true;
            }
            updatePlayPauseButton();
        }

        function stopAudio() {
            audioPlayers.forEach(player => {
                player.wavesurfer.stop();
            });
            isPlaying = false;
            updatePlayPauseButton();
        }

        function stopAndClose() {
            stopAudio();
            setTimeout(() => window.close(), 100);
        }

        function increaseVolume() {
            const currentPlayer = audioPlayers[currentPlayerIndex];
            currentPlayer.slider.stepUp();
            adjustVolume();
        }

        function decreaseVolume() {
            const currentPlayer = audioPlayers[currentPlayerIndex];
            currentPlayer.slider.stepDown();
            adjustVolume();
        }

        function resetAllPlayers() {
            audioPlayers.forEach(player => {
                player.wavesurfer.seekTo(0);
            });
        }

        function playAllPlayers() {
            audioPlayers.forEach((player, i) => {
                if (i === currentPlayerIndex) {
                    player.wavesurfer.play();
                } else {
                    player.wavesurfer.pause();
                }
            });
            isPlaying = true;
            updatePlayPauseButton();
        }

        function preloadAudioPlayers() {
            const files = ["", ""];

            for (let i = 0; i < files.length; i++) {
                const id = String.fromCharCode(65 + i);
                createAudioPlayer(id, i);
                const player = audioPlayers[i];
                if (files[i]) {
                    player.wavesurfer.load(files[i]);
                    player.filename.textContent = files[i].split("/").pop();
                }
                    setTimeout(kmResize, 100);
            }
            
            isPlaying = false;
            updatePlayPauseButton();
        }

        function playAudio() {
            audioPlayers.forEach((player, i) => {
                player.wavesurfer.setMute(i !== currentPlayerIndex);
                if (isPlaying) {
                    player.wavesurfer.play();
                }
            });
            highlightCurrentPlayer();
        }

        function highlightCurrentPlayer() {
            audioPlayers.forEach((player, i) => {
                player.container.classList.toggle('highlight', i === currentPlayerIndex);
            });
        }

function switchAudio() {
    currentPlayerIndex = (currentPlayerIndex + 1) % audioPlayers.length;
    setCurrentPlayer(currentPlayerIndex);
}

function switchAudioReverse() {
    currentPlayerIndex = (currentPlayerIndex - 1 + audioPlayers.length) % audioPlayers.length;
    setCurrentPlayer(currentPlayerIndex);
}

        function adjustVolume() {
            audioPlayers.forEach(player => {
                player.wavesurfer.setVolume(player.slider.value);
            });
        }

        function rewind(seconds) {
            audioPlayers.forEach(player => {
                const currentTime = player.wavesurfer.getCurrentTime();
                player.wavesurfer.seekTo(Math.max(0, (currentTime - seconds) / player.wavesurfer.getDuration()));
            });
        }

        function forward(seconds) {
            audioPlayers.forEach(player => {
                const currentTime = player.wavesurfer.getCurrentTime();
                player.wavesurfer.seekTo(Math.min(1, (currentTime + seconds) / player.wavesurfer.getDuration()));
            });
        }

        function updatePlayPauseButton() {
            const playIcon = document.getElementById('playIcon');
            const pauseIcon = document.getElementById('pauseIcon');
            const pauseIcon2 = document.getElementById('pauseIcon2');
            
            if (isPlaying) {
                playIcon.style.display = 'none';
                pauseIcon.style.display = 'block';
                pauseIcon2.style.display = 'block';
            } else {
                playIcon.style.display = 'block';
                pauseIcon.style.display = 'none';
                pauseIcon2.style.display = 'none';
            }
        }

        function pauseAndResetPlayers() {
            audioPlayers.forEach(player => {
                player.wavesurfer.stop();
            });
        }
        
         function setInitialWidth() {
            const body = document.body;
            const html = document.documentElement;

            const height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight);

            window.KeyboardMaestro.ResizeWindow("700," + height);
        }

        
function kmResize() {
            const body = document.body;
            const html = document.documentElement;

            const height = Math.max(body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight);

            window.KeyboardMaestro.ResizeWindow("700," + height);
        }
        
        window.addEventListener('beforeunload', function(event) {
            audioPlayers.forEach(player => player.wavesurfer.stop());
        });

        document.body.addEventListener('dragover', function(e) {
            e.preventDefault();
        });

        document.getElementById('rewind10Button').addEventListener('click', () => rewind(10));
        document.getElementById('rewind3Button').addEventListener('click', () => rewind(3));
        document.getElementById('playPauseButton').addEventListener('click', togglePlayPause);
        document.getElementById('forward3Button').addEventListener('click', () => forward(3));
        document.getElementById('forward10Button').addEventListener('click', () => forward(10));
document.getElementById('abButton').addEventListener('click', () => {
    switchAudio();
    abButton.textContent = (currentPlayerIndex + 1).toString();
});
        document.getElementById('loopButton').addEventListener('click', toggleLoop);

        document.getElementById('addPlayerButton').addEventListener('click', () => {
            const newPlayerIndex = audioPlayers.length;
            const id = String.fromCharCode(65 + newPlayerIndex);
            createAudioPlayer(id, newPlayerIndex);
        });
    </script>
<body onload="kmResize()">
</html>

@peternlewis...

I've put a good many hours into trying to load files into audio players that show a wavesurfer.js waveform overview in the Custom Floating HTML Prompt action. I can drag and drop files no problem, but if I try to reference file paths, there seems to be some kind of permissions/security issue related to the Web Audio API.

Before I put any more time into this, would you be able to confirm whether this is a dead-end? I've tried everything I can think of, but can't seem to reference filepaths successfully unless I'm using the native audio player. To reiterate, dragging and dropping works perfectly, which is even more confounding.

"I dunno" is a legitimate answer, as I realise this may not be something many people would have cause to look into.

Can I ask for one last favor? Can you add a “trash” button so that I can quickly delete a
file if I don’t want it? This would be more useful, especially when we have multiple files to compare. Greatly appreciated.

Done, as well as a few refinements.

Just updated the web version with a volume match feature! Mad props to Claude.ai for helping with the maths required. :ok_hand:t3:

1 Like

Web version (which is the only one I care about now) updated with a blind test mode.

Updated with a button that plays the audio files at random, which is useful in the blind test mode.

Updated with auto-scrolling in the main view so that the playback controls are always visible.