// ==UserScript==
// @name Download ChatGPT Voice Audio
// @namespace ViolentMonkeyScript
// @match *://chat.openai.com/*
// @match *://chatgpt.com/*
// @version 3.5
// @description Adds a download button for voice audio files
// @grant none
// @run-at document-start
// @inject-into page
// @license MIT
// ==/UserScript==
(function() {
'use strict';
console.log('Script is running at document-start');
let shouldStopAudioPlayback = false;
let shouldDownloadSynthesizedAudio = false;
let currentDownloadButton = null;
// Save the original fetch function
const originalFetch = window.fetch;
// Override the fetch function
window.fetch = function(...args) {
const resource = args[0];
const config = args[1];
// Get the URL from the resource
let url = resource instanceof Request ? resource.url : resource;
// Check if the request URL includes 'backend-api/synthesize'
if (typeof url === 'string' && url.includes('/backend-api/synthesize')) {
console.log('Intercepted fetch:', url);
if (shouldDownloadSynthesizedAudio) {
shouldDownloadSynthesizedAudio = false; // Reset the flag
// Extract message_id from the URL parameters
const urlParams = new URL(url, window.location.origin);
const messageId = urlParams.searchParams.get('message_id');
// Generate filename
let fileName = 'response.aac'; // Default filename
if (messageId) {
const prefix = messageId.split('-')[0];
fileName = `${prefix}.aac`;
}
return originalFetch(...args)
.then(response => {
// Clone the response
const responseClone = response.clone();
// Convert response to Blob and download
responseClone.blob().then(blob => {
const objectURL = URL.createObjectURL(blob);
console.log('Object URL:', objectURL);
const a = document.createElement('a');
a.href = objectURL;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke the object URL after download
URL.revokeObjectURL(objectURL);
// Restore the button after download
if (currentDownloadButton) {
restoreDownloadButton(currentDownloadButton);
currentDownloadButton = null;
}
}).catch(error => {
console.error('Error processing the blob:', error);
// Restore the button on error
if (currentDownloadButton) {
restoreDownloadButton(currentDownloadButton);
currentDownloadButton = null;
}
});
// Return the original response
return response;
})
.catch(error => {
console.error('Error fetching the response:', error);
// Restore the button on error
if (currentDownloadButton) {
restoreDownloadButton(currentDownloadButton);
currentDownloadButton = null;
}
throw error;
});
} else {
// Proceed with the fetch normally
return originalFetch(...args);
}
} else {
// For other fetch requests
return originalFetch(...args);
}
};
// Wait until the DOM is ready
document.addEventListener('DOMContentLoaded', function() {
waitForElements();
});
// Monitor 'play' events on audio elements
document.addEventListener('play', function(e) {
const audioElement = e.target;
if (audioElement.tagName === 'AUDIO') {
if (shouldStopAudioPlayback) {
audioElement.pause();
audioElement.currentTime = 0;
shouldStopAudioPlayback = false;
console.log('Audio playback stopped');
}
}
}, true); // Use capture phase to catch events from all elements
// Function to wait for the elements to be available
function waitForElements() {
const originalButtons = document.querySelectorAll('button[data-testid="voice-play-turn-action-button"]');
if (originalButtons && originalButtons.length > 0) {
console.log('Original buttons are now available');
injectNewButtons();
observeDOM(); // Start observing after initial buttons are injected
} else {
console.log('Original buttons not yet available, retrying in 500ms...');
setTimeout(waitForElements, 500);
}
}
// Function to create a tooltip element
function createTooltip(text) {
const tooltip = document.createElement('div');
tooltip.setAttribute('data-radix-popper-content-wrapper', '');
tooltip.style.position = 'fixed';
tooltip.style.left = '0px';
tooltip.style.top = '0px';
tooltip.style.zIndex = '50';
tooltip.style.minWidth = 'max-content';
tooltip.style.willChange = 'transform';
tooltip.style.pointerEvents = 'none'; // Add this line
const innerTooltip = document.createElement('div');
innerTooltip.setAttribute('data-side', 'bottom');
innerTooltip.setAttribute('data-align', 'center');
innerTooltip.className = 'relative z-50 shadow-xs transition-opacity px-3 py-2 rounded-lg border-white/10 dark:border bg-gray-950 max-w-xs';
const textSpan = document.createElement('span');
textSpan.className = 'flex items-center whitespace-pre-wrap text-center font-semibold normal-case text-gray-100 text-sm';
textSpan.textContent = text;
const arrow = document.createElement('span');
arrow.style.position = 'absolute';
arrow.style.top = '0px';
arrow.style.transformOrigin = 'center 0px';
arrow.style.transform = 'rotate(180deg)';
arrow.style.left = '50.5px';
const arrowDiv = document.createElement('div');
arrowDiv.className = 'relative top-[-4px] h-2 w-2 rotate-45 transform shadow-xs dark:border-r dark:border-b border-white/10 bg-gray-950';
arrow.appendChild(arrowDiv);
innerTooltip.appendChild(textSpan);
innerTooltip.appendChild(arrow);
tooltip.appendChild(innerTooltip);
return tooltip;
}
// Function to add tooltip functionality to a button
function addTooltipToButton(button) {
let tooltip = null;
let showTimeout;
button.addEventListener('mouseenter', () => {
showTimeout = setTimeout(() => {
if (!tooltip) {
tooltip = createTooltip('Download Audio');
document.body.appendChild(tooltip);
}
const buttonRect = button.getBoundingClientRect();
tooltip.style.transform = `translate(${buttonRect.left + buttonRect.width / 2 - 54.5}px, ${buttonRect.bottom + 8}px)`;
tooltip.querySelector('[data-side]').setAttribute('data-state', 'delayed-open');
}, 300);
});
button.addEventListener('mouseleave', () => {
clearTimeout(showTimeout);
if (tooltip && tooltip.parentNode) {
tooltip.parentNode.removeChild(tooltip);
tooltip = null;
}
});
}
// Modified injectNewButtons function
function injectNewButtons() {
const originalButtons = document.querySelectorAll('button[data-testid="voice-play-turn-action-button"]');
console.log('Number of original buttons found:', originalButtons.length);
originalButtons.forEach(originalButton => {
// Get the parent span of the original button
const originalSpan = originalButton.closest('span[data-state]');
if (!originalSpan) {
console.error('Parent span not found for an original button!');
return;
}
// Check if the download button already exists after the original span
if (originalSpan.nextElementSibling && originalSpan.nextElementSibling.querySelector('button.download-audio-button')) {
return;
}
// Create a new span for the new button
const newSpan = document.createElement('span');
newSpan.className = originalSpan.className; // Copy class
newSpan.setAttribute('data-state', originalSpan.getAttribute('data-state')); // Copy data-state
// Create the new button as before
const newButton = document.createElement('button');
newButton.classList.add('rounded-lg', 'text-token-text-secondary', 'hover:bg-token-main-surface-secondary', 'download-audio-button');
newButton.setAttribute('aria-label', 'Download Audio');
newButton.setAttribute('data-testid', 'download-audio-button');
const span = document.createElement('span');
span.classList.add('flex', 'h-[30px]', 'w-[30px]', 'items-center', 'justify-center');
// Create the SVG icon for the button
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '24');
svg.setAttribute('height', '24');
svg.setAttribute('viewBox', '0 0 24 24');
svg.classList.add('icon-md-heavy');
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('fill-rule', 'evenodd');
path.setAttribute('clip-rule', 'evenodd');
path.setAttribute('d', 'M5 20H19V18H5M19 9H15V3H9V9H5L12 16L19 9Z'); // Download icon path
path.setAttribute('fill', 'currentColor');
svg.appendChild(path);
span.appendChild(svg);
newButton.appendChild(span);
// Append the new button to the new span
newSpan.appendChild(newButton);
// Insert the new span after the original span
originalSpan.insertAdjacentElement('afterend', newSpan);
// Add tooltip functionality
addTooltipToButton(newButton);
addClickHandler(newButton, originalButton);
});
}
function addClickHandler(newButton, originalButton) {
newButton.addEventListener('click', function() {
console.log('Download button clicked');
// Set the flag to stop audio playback
shouldStopAudioPlayback = true;
// Save reference to the current button
currentDownloadButton = newButton;
// Change the button to loading state
setButtonLoadingState(newButton);
// Record the current timestamp
const startTime = performance.now();
// Set the flag to download synthesized audio
shouldDownloadSynthesizedAudio = true;
// Simulate click on the original 'Replay' button
originalButton.click();
// Attempt to capture the audio URL
waitForAudioURL(startTime).then(function(audioURL) {
if (audioURL) {
// New conversation handling
console.log('Captured audio URL via Performance API:', audioURL);
downloadAudio(audioURL);
} else {
// Old conversation handling
console.log('Attempting to download synthesized audio');
// The fetch override should handle the download
// If not, restore the button
setTimeout(() => {
if (currentDownloadButton) {
restoreDownloadButton(currentDownloadButton);
currentDownloadButton = null;
}
}, 5000); // Adjust timeout as needed
}
});
});
}
function setButtonLoadingState(button) {
// Disable the button
button.disabled = true;
button.setAttribute('aria-label', 'Loading');
button.setAttribute('data-testid', 'loading-download-button');
// Clear existing content
button.innerHTML = '';
// Create the span inside the button
const span = document.createElement('span');
span.classList.add('flex', 'h-[30px]', 'w-[30px]', 'items-center', 'justify-center');
// Create the loading spinner SVG
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.classList.add('animate-spin', 'text-center', 'icon-md-heavy');
svg.setAttribute('height', '1em');
svg.setAttribute('width', '1em');
// Add lines to the spinner SVG
const lines = [
{ x1: 12, y1: 2, x2: 12, y2: 6 },
{ x1: 12, y1: 18, x2: 12, y2: 22 },
{ x1: 4.93, y1: 4.93, x2: 7.76, y2: 7.76 },
{ x1: 16.24, y1: 16.24, x2: 19.07, y2: 19.07 },
{ x1: 2, y1: 12, x2: 6, y2: 12 },
{ x1: 18, y1: 12, x2: 22, y2: 12 },
{ x1: 4.93, y1: 19.07, x2: 7.76, y2: 16.24 },
{ x1: 16.24, y1: 7.76, x2: 19.07, y2: 4.93 },
];
lines.forEach(lineData => {
const line = document.createElementNS(svgNS, 'line');
line.setAttribute('x1', lineData.x1);
line.setAttribute('y1', lineData.y1);
line.setAttribute('x2', lineData.x2);
line.setAttribute('y2', lineData.y2);
svg.appendChild(line);
});
// Append the SVG to the span
span.appendChild(svg);
// Append the span to the button
button.appendChild(span);
}
function restoreDownloadButton(button) {
// Enable the button
button.disabled = false;
button.setAttribute('aria-label', 'Download Audio');
button.setAttribute('data-testid', 'download-audio-button');
// Clear existing content
button.innerHTML = '';
// Create the span inside the button
const span = document.createElement('span');
span.classList.add('flex', 'h-[30px]', 'w-[30px]', 'items-center', 'justify-center');
// Create the SVG element (download icon)
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '24');
svg.setAttribute('height', '24');
svg.setAttribute('viewBox', '0 0 24 24');
svg.classList.add('icon-md-heavy');
// Create the path element
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('fill-rule', 'evenodd');
path.setAttribute('clip-rule', 'evenodd');
path.setAttribute('d', 'M5 20H19V18H5M19 9H15V3H9V9H5L12 16L19 9Z'); // Download icon path
path.setAttribute('fill', 'currentColor');
// Append the path to the SVG
svg.appendChild(path);
// Append the SVG to the span
span.appendChild(svg);
// Append the span to the button
button.appendChild(span);
}
function downloadAudio(url) {
fetch(url)
.then(response => response.blob())
.then(blob => {
const objectURL = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectURL;
a.download = 'audio.wav'; // Customize the filename if needed
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(objectURL);
console.log('Audio download completed');
if (currentDownloadButton) {
restoreDownloadButton(currentDownloadButton);
currentDownloadButton = null;
}
})
.catch(error => {
console.error('Audio download failed', error);
if (currentDownloadButton) {
restoreDownloadButton(currentDownloadButton);
currentDownloadButton = null;
}
});
}
function waitForAudioURL(startTime, timeout = 5000) {
return new Promise(function(resolve, reject) {
const interval = 100;
let elapsedTime = 0;
const checkURL = setInterval(function() {
const audioURL = getLatestAudioRequestURL(startTime);
if (audioURL) {
clearInterval(checkURL);
resolve(audioURL);
} else if (elapsedTime >= timeout) {
clearInterval(checkURL);
resolve(null);
} else {
elapsedTime += interval;
}
}, interval);
});
}
function getLatestAudioRequestURL(startTime) {
const entries = performance.getEntriesByType('resource');
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.startTime < startTime) {
break;
}
// Check for any oaiusercontent.com URL
if (entry.name.includes('oaiusercontent.com')) {
console.log('Captured audio URL via Performance API:', entry.name);
return entry.name;
}
}
return null;
}
function observeDOM() {
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
// Check if added nodes contain new 'Replay' buttons
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.matches('button[data-testid="voice-play-turn-action-button"]') ||
node.querySelector('button[data-testid="voice-play-turn-action-button"]')) {
console.log('New Replay button detected, injecting Download buttons...');
injectNewButtons();
}
}
});
});
});
const threadsContainer = document.body; // Adjust this selector if necessary
if (threadsContainer) {
observer.observe(threadsContainer, {
childList: true,
subtree: true
});
console.log('MutationObserver is now observing the DOM for changes.');
} else {
console.error('Threads container not found for MutationObserver!');
}
}
})();