// ==UserScript==
// @name Download ChatGPT Voice Audio
// @namespace ViolentMonkeyScript
// @match *://chat.openai.com/*
// @match *://chatgpt.com/*
// @version 3.2
// @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 inject new buttons next to each 'Replay' button
function injectNewButtons() {
// Get all the 'Replay' buttons
const originalButtons = document.querySelectorAll('button[data-testid="voice-play-turn-action-button"]');
console.log('Number of original buttons found:', originalButtons.length);
originalButtons.forEach(originalButton => {
// Find the parent element to inject the new button into
let parentElement = originalButton.closest('.flex.items-center');
if (!parentElement) {
console.error('Parent element not found for an original button!');
return;
}
// Check if the button is already injected to avoid duplicates
if (parentElement.querySelector('.download-audio-button')) {
return;
}
// Create the new button element
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');
// 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
newButton.appendChild(span);
// Append the new button to the parent element
parentElement.appendChild(newButton);
// Determine if this is an old or new conversation
// const isOldConversation = detectOldConversation(originalButton);
addClickHandler(newButton, originalButton);
// // Add click event listener to the new button
// if (isOldConversation) {
// addClickHandlerToOldButton(newButton, originalButton);
// } else {
// addClickHandlerToNewButton(newButton, originalButton);
// }
});
}
// Function to detect if the conversation is old
function detectOldConversation(originalButton) {
// Get the 'svg' element within the button
const svgElement = originalButton.querySelector('svg');
if (!svgElement) {
console.warn('SVG element not found within the Replay button.');
return false;
}
// Find the 'path' element within the 'svg'
const pathElement = svgElement.querySelector('path');
if (!pathElement) {
console.warn('Path element not found within the SVG.');
return false;
}
// Get the 'd' attribute of the 'path' element
const dAttribute = pathElement.getAttribute('d');
// SVG 'd' attribute for the old conversation Replay button
const oldReplayButtonDAttribute = 'M11 4.9099C11 4.47485 10.4828 4.24734 10.1621 4.54132L6.67572 7.7372C6.49129 7.90626 6.25019 8.00005 6 8.00005H4C3.44772 8.00005 3 8.44776 3 9.00005V15C3 15.5523 3.44772 16 4 16H6C6.25019 16 6.49129 16.0938 6.67572 16.2629L10.1621 19.4588C10.4828 19.7527 11 19.5252 11 19.0902V4.9099ZM8.81069 3.06701C10.4142 1.59714 13 2.73463 13 4.9099V19.0902C13 21.2655 10.4142 22.403 8.81069 20.9331L5.61102 18H4C2.34315 18 1 16.6569 1 15V9.00005C1 7.34319 2.34315 6.00005 4 6.00005H5.61102L8.81069 3.06701ZM20.3166 6.35665C20.8019 6.09313 21.409 6.27296 21.6725 6.75833C22.5191 8.3176 22.9996 10.1042 22.9996 12.0001C22.9996 13.8507 22.5418 15.5974 21.7323 17.1302C21.4744 17.6185 20.8695 17.8054 20.3811 17.5475C19.8927 17.2896 19.7059 16.6846 19.9638 16.1962C20.6249 14.9444 20.9996 13.5175 20.9996 12.0001C20.9996 10.4458 20.6064 8.98627 19.9149 7.71262C19.6514 7.22726 19.8312 6.62017 20.3166 6.35665ZM15.7994 7.90049C16.241 7.5688 16.8679 7.65789 17.1995 8.09947C18.0156 9.18593 18.4996 10.5379 18.4996 12.0001C18.4996 13.3127 18.1094 14.5372 17.4385 15.5604C17.1357 16.0222 16.5158 16.1511 16.0539 15.8483C15.5921 15.5455 15.4632 14.9255 15.766 14.4637C16.2298 13.7564 16.4996 12.9113 16.4996 12.0001C16.4996 10.9859 16.1653 10.0526 15.6004 9.30063C15.2687 8.85905 15.3578 8.23218 15.7994 7.90049Z';
// Compare the 'd' attribute to the known value for the old conversation icon
if (dAttribute === oldReplayButtonDAttribute) {
return true; // It's an old conversation
} else {
return false; // It's a new conversation
}
}
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 to handle the click event for old conversations
function addClickHandlerToOldButton(newButton, originalButton) {
newButton.addEventListener('click', function() {
console.log('Simulated click on the original button (old conversation)');
// Set the flag to stop audio playback
shouldStopAudioPlayback = true;
// Set the flag to download synthesized audio
shouldDownloadSynthesizedAudio = true;
// Save reference to the current button
currentDownloadButton = newButton;
// Change the button to loading state
setButtonLoadingState(newButton);
// Simulate click on the original 'Replay' button
originalButton.click();
});
}
// Function to handle the click event for new conversations
function addClickHandlerToNewButton(newButton, originalButton) {
newButton.addEventListener('click', function() {
console.log('Simulated click on the original button (new conversation)');
// Set the flag to stop audio playback
shouldStopAudioPlayback = true;
// Record the current timestamp
const startTime = performance.now();
// Save reference to the current button
currentDownloadButton = newButton;
// Change the button to loading state
setButtonLoadingState(newButton);
// Simulate click on the original 'Replay' button
originalButton.click();
// Wait for the audio URL to be captured
waitForAudioURL(startTime).then(function(audioURL) {
if (audioURL) {
// Initiate download
downloadAudio(audioURL);
} else {
console.error('Failed to capture the audio URL.');
restoreDownloadButton(currentDownloadButton);
currentDownloadButton = null;
}
});
});
}
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 20h14v-2H5v2zm7-18L5 9h4v4h6V9h4L12 2z'); // 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;
}
if (entry.name.includes('sdmntprwestus.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!');
}
}
})();