2025-01-22 21:11:47 -07:00
|
|
|
|
class KokoroPlayer {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.elements = {
|
|
|
|
|
textInput: document.getElementById('text-input'),
|
2025-01-23 02:00:46 -07:00
|
|
|
|
voiceSearch: document.getElementById('voice-search'),
|
|
|
|
|
voiceDropdown: document.getElementById('voice-dropdown'),
|
|
|
|
|
voiceOptions: document.getElementById('voice-options'),
|
|
|
|
|
selectedVoices: document.getElementById('selected-voices'),
|
2025-01-22 21:11:47 -07:00
|
|
|
|
autoplayToggle: document.getElementById('autoplay-toggle'),
|
2025-01-23 02:00:46 -07:00
|
|
|
|
formatSelect: document.getElementById('format-select'),
|
2025-01-22 21:11:47 -07:00
|
|
|
|
generateBtn: document.getElementById('generate-btn'),
|
2025-01-23 02:00:46 -07:00
|
|
|
|
cancelBtn: document.getElementById('cancel-btn'),
|
|
|
|
|
playPauseBtn: document.getElementById('play-pause-btn'),
|
|
|
|
|
waveContainer: document.getElementById('wave-container'),
|
|
|
|
|
timeDisplay: document.getElementById('time-display'),
|
|
|
|
|
downloadBtn: document.getElementById('download-btn'),
|
2025-01-23 04:54:55 -07:00
|
|
|
|
status: document.getElementById('status'),
|
|
|
|
|
speedSlider: document.getElementById('speed-slider'),
|
|
|
|
|
speedValue: document.getElementById('speed-value')
|
2025-01-22 21:11:47 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.isGenerating = false;
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.availableVoices = [];
|
|
|
|
|
this.selectedVoiceSet = new Set();
|
|
|
|
|
this.currentController = null;
|
|
|
|
|
this.audioChunks = [];
|
|
|
|
|
this.sound = null;
|
|
|
|
|
this.wave = null;
|
2025-01-22 21:11:47 -07:00
|
|
|
|
this.init();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async init() {
|
|
|
|
|
await this.loadVoices();
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.setupWave();
|
2025-01-22 21:11:47 -07:00
|
|
|
|
this.setupEventListeners();
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.setupAudioControls();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setupWave() {
|
|
|
|
|
this.wave = new SiriWave({
|
|
|
|
|
container: this.elements.waveContainer,
|
|
|
|
|
width: this.elements.waveContainer.clientWidth,
|
2025-01-23 04:11:31 -07:00
|
|
|
|
height: 80,
|
2025-01-24 05:01:38 -07:00
|
|
|
|
style: 'ios9',
|
2025-01-23 04:11:31 -07:00
|
|
|
|
// color: '#6366f1',
|
2025-01-23 02:00:46 -07:00
|
|
|
|
speed: 0.02,
|
|
|
|
|
amplitude: 0.7,
|
|
|
|
|
frequency: 4
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formatTime(secs) {
|
|
|
|
|
const minutes = Math.floor(secs / 60);
|
|
|
|
|
const seconds = Math.floor(secs % 60);
|
|
|
|
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateTimeDisplay() {
|
|
|
|
|
if (!this.sound) return;
|
|
|
|
|
const seek = this.sound.seek() || 0;
|
|
|
|
|
const duration = this.sound.duration() || 0;
|
|
|
|
|
this.elements.timeDisplay.textContent = `${this.formatTime(seek)} / ${this.formatTime(duration)}`;
|
|
|
|
|
|
|
|
|
|
// Update seek slider
|
|
|
|
|
const seekSlider = document.getElementById('seek-slider');
|
|
|
|
|
seekSlider.value = (seek / duration) * 100 || 0;
|
|
|
|
|
|
|
|
|
|
if (this.sound.playing()) {
|
|
|
|
|
requestAnimationFrame(() => this.updateTimeDisplay());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setupAudioControls() {
|
|
|
|
|
const seekSlider = document.getElementById('seek-slider');
|
|
|
|
|
const volumeSlider = document.getElementById('volume-slider');
|
|
|
|
|
|
|
|
|
|
seekSlider.addEventListener('input', (e) => {
|
|
|
|
|
if (!this.sound) return;
|
|
|
|
|
const duration = this.sound.duration();
|
|
|
|
|
const seekTime = (duration * e.target.value) / 100;
|
|
|
|
|
this.sound.seek(seekTime);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
volumeSlider.addEventListener('input', (e) => {
|
|
|
|
|
if (!this.sound) return;
|
|
|
|
|
const volume = e.target.value / 100;
|
|
|
|
|
this.sound.volume(volume);
|
|
|
|
|
});
|
2025-01-22 21:11:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadVoices() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/v1/audio/voices');
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new Error(error.detail?.message || 'Failed to load voices');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (!data.voices?.length) {
|
|
|
|
|
throw new Error('No voices available');
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.availableVoices = data.voices;
|
|
|
|
|
this.renderVoiceOptions(this.availableVoices);
|
2025-01-22 21:11:47 -07:00
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
if (this.selectedVoiceSet.size === 0) {
|
|
|
|
|
const firstVoice = this.availableVoices.find(voice => voice && voice.trim());
|
|
|
|
|
if (firstVoice) {
|
|
|
|
|
this.addSelectedVoice(firstVoice);
|
|
|
|
|
}
|
2025-01-22 21:11:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.showStatus('Voices loaded successfully', 'success');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.showStatus('Failed to load voices: ' + error.message, 'error');
|
|
|
|
|
this.elements.generateBtn.disabled = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
renderVoiceOptions(voices) {
|
|
|
|
|
this.elements.voiceOptions.innerHTML = voices
|
|
|
|
|
.map(voice => `
|
|
|
|
|
<label class="voice-option">
|
|
|
|
|
<input type="checkbox" value="${voice}"
|
|
|
|
|
${this.selectedVoiceSet.has(voice) ? 'checked' : ''}>
|
|
|
|
|
${voice}
|
|
|
|
|
</label>
|
|
|
|
|
`)
|
|
|
|
|
.join('');
|
|
|
|
|
this.updateSelectedVoicesDisplay();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateSelectedVoicesDisplay() {
|
|
|
|
|
this.elements.selectedVoices.innerHTML = Array.from(this.selectedVoiceSet)
|
|
|
|
|
.map(voice => `
|
|
|
|
|
<span class="selected-voice-tag">
|
|
|
|
|
${voice}
|
|
|
|
|
<span class="remove-voice" data-voice="${voice}">×</span>
|
|
|
|
|
</span>
|
|
|
|
|
`)
|
|
|
|
|
.join('');
|
|
|
|
|
|
|
|
|
|
if (this.selectedVoiceSet.size > 0) {
|
|
|
|
|
this.elements.voiceSearch.placeholder = 'Search voices...';
|
|
|
|
|
} else {
|
|
|
|
|
this.elements.voiceSearch.placeholder = 'Search and select voices...';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addSelectedVoice(voice) {
|
|
|
|
|
this.selectedVoiceSet.add(voice);
|
|
|
|
|
this.updateSelectedVoicesDisplay();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removeSelectedVoice(voice) {
|
|
|
|
|
this.selectedVoiceSet.delete(voice);
|
|
|
|
|
this.updateSelectedVoicesDisplay();
|
|
|
|
|
const checkbox = this.elements.voiceOptions.querySelector(`input[value="${voice}"]`);
|
|
|
|
|
if (checkbox) checkbox.checked = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filterVoices(searchTerm) {
|
|
|
|
|
const filtered = this.availableVoices.filter(voice =>
|
|
|
|
|
voice.toLowerCase().includes(searchTerm.toLowerCase())
|
|
|
|
|
);
|
|
|
|
|
this.renderVoiceOptions(filtered);
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-22 21:11:47 -07:00
|
|
|
|
setupEventListeners() {
|
2025-01-23 02:00:46 -07:00
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
|
|
|
if (this.currentController) {
|
|
|
|
|
this.currentController.abort();
|
|
|
|
|
}
|
|
|
|
|
if (this.sound) {
|
|
|
|
|
this.sound.unload();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.elements.voiceSearch.addEventListener('input', (e) => {
|
|
|
|
|
this.filterVoices(e.target.value);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.elements.voiceOptions.addEventListener('change', (e) => {
|
|
|
|
|
if (e.target.type === 'checkbox') {
|
|
|
|
|
if (e.target.checked) {
|
|
|
|
|
this.addSelectedVoice(e.target.value);
|
|
|
|
|
} else {
|
|
|
|
|
this.removeSelectedVoice(e.target.value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.elements.selectedVoices.addEventListener('click', (e) => {
|
|
|
|
|
if (e.target.classList.contains('remove-voice')) {
|
|
|
|
|
const voice = e.target.dataset.voice;
|
|
|
|
|
this.removeSelectedVoice(voice);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-01-22 21:11:47 -07:00
|
|
|
|
this.elements.generateBtn.addEventListener('click', () => this.generateSpeech());
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.elements.cancelBtn.addEventListener('click', () => this.cancelGeneration());
|
|
|
|
|
this.elements.playPauseBtn.addEventListener('click', () => this.togglePlayPause());
|
|
|
|
|
this.elements.downloadBtn.addEventListener('click', () => this.downloadAudio());
|
|
|
|
|
|
2025-01-23 04:54:55 -07:00
|
|
|
|
this.elements.speedSlider.addEventListener('input', (e) => {
|
|
|
|
|
const speed = parseFloat(e.target.value);
|
|
|
|
|
this.elements.speedValue.textContent = speed.toFixed(1);
|
|
|
|
|
});
|
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
document.addEventListener('click', (e) => {
|
|
|
|
|
if (!this.elements.voiceSearch.contains(e.target) &&
|
|
|
|
|
!this.elements.voiceDropdown.contains(e.target)) {
|
|
|
|
|
this.elements.voiceDropdown.style.display = 'none';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.elements.voiceSearch.addEventListener('focus', () => {
|
|
|
|
|
this.elements.voiceDropdown.style.display = 'block';
|
|
|
|
|
if (!this.elements.voiceSearch.value) {
|
|
|
|
|
this.elements.voiceSearch.placeholder = 'Search voices...';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.elements.voiceSearch.addEventListener('blur', () => {
|
|
|
|
|
if (!this.elements.voiceSearch.value && this.selectedVoiceSet.size === 0) {
|
|
|
|
|
this.elements.voiceSearch.placeholder = 'Search and select voices...';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
if (this.wave) {
|
|
|
|
|
this.wave.width = this.elements.waveContainer.clientWidth;
|
|
|
|
|
}
|
2025-01-22 21:11:47 -07:00
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showStatus(message, type = 'info') {
|
|
|
|
|
this.elements.status.textContent = message;
|
|
|
|
|
this.elements.status.className = 'status ' + type;
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.elements.status.className = 'status';
|
|
|
|
|
}, 5000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setLoading(loading) {
|
|
|
|
|
this.isGenerating = loading;
|
|
|
|
|
this.elements.generateBtn.disabled = loading;
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.elements.generateBtn.className = loading ? 'loading' : '';
|
|
|
|
|
this.elements.cancelBtn.style.display = loading ? 'block' : 'none';
|
2025-01-22 21:11:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validateInput() {
|
|
|
|
|
const text = this.elements.textInput.value.trim();
|
|
|
|
|
if (!text) {
|
|
|
|
|
this.showStatus('Please enter some text', 'error');
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
if (this.selectedVoiceSet.size === 0) {
|
2025-01-22 21:11:47 -07:00
|
|
|
|
this.showStatus('Please select a voice', 'error');
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
cancelGeneration() {
|
|
|
|
|
if (this.currentController) {
|
|
|
|
|
this.currentController.abort();
|
|
|
|
|
this.currentController = null;
|
|
|
|
|
if (this.sound) {
|
|
|
|
|
this.sound.unload();
|
|
|
|
|
this.sound = null;
|
|
|
|
|
}
|
|
|
|
|
this.wave.stop();
|
|
|
|
|
this.showStatus('Generation cancelled', 'info');
|
|
|
|
|
this.setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
togglePlayPause() {
|
|
|
|
|
if (!this.sound) return;
|
|
|
|
|
|
|
|
|
|
if (this.sound.playing()) {
|
|
|
|
|
this.sound.pause();
|
|
|
|
|
this.wave.stop();
|
|
|
|
|
this.elements.playPauseBtn.textContent = 'Play';
|
|
|
|
|
} else {
|
|
|
|
|
this.sound.play();
|
|
|
|
|
this.wave.start();
|
|
|
|
|
this.elements.playPauseBtn.textContent = 'Pause';
|
|
|
|
|
this.updateTimeDisplay();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-22 21:11:47 -07:00
|
|
|
|
async generateSpeech() {
|
|
|
|
|
if (this.isGenerating || !this.validateInput()) return;
|
2025-01-23 02:00:46 -07:00
|
|
|
|
|
|
|
|
|
if (this.sound) {
|
|
|
|
|
this.sound.unload();
|
|
|
|
|
this.sound = null;
|
|
|
|
|
}
|
|
|
|
|
this.wave.stop();
|
|
|
|
|
|
|
|
|
|
this.elements.downloadBtn.style.display = 'none';
|
|
|
|
|
this.audioChunks = [];
|
2025-01-22 21:11:47 -07:00
|
|
|
|
|
|
|
|
|
const text = this.elements.textInput.value.trim();
|
2025-01-23 02:00:46 -07:00
|
|
|
|
const voice = Array.from(this.selectedVoiceSet).join('+');
|
2025-01-22 21:11:47 -07:00
|
|
|
|
|
|
|
|
|
this.setLoading(true);
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.currentController = new AbortController();
|
2025-01-22 21:11:47 -07:00
|
|
|
|
|
|
|
|
|
try {
|
2025-01-23 02:00:46 -07:00
|
|
|
|
await this.handleAudio(text, voice);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error.name === 'AbortError') {
|
|
|
|
|
this.showStatus('Generation cancelled', 'info');
|
2025-01-22 21:11:47 -07:00
|
|
|
|
} else {
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.showStatus('Error generating speech: ' + error.message, 'error');
|
2025-01-22 21:11:47 -07:00
|
|
|
|
}
|
|
|
|
|
} finally {
|
2025-01-23 02:00:46 -07:00
|
|
|
|
this.currentController = null;
|
2025-01-22 21:11:47 -07:00
|
|
|
|
this.setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
async handleAudio(text, voice) {
|
|
|
|
|
this.showStatus('Generating audio...', 'info');
|
2025-01-22 21:11:47 -07:00
|
|
|
|
|
|
|
|
|
const response = await fetch('/v1/audio/speech', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
input: text,
|
|
|
|
|
voice: voice,
|
|
|
|
|
response_format: 'mp3',
|
2025-01-23 04:54:55 -07:00
|
|
|
|
stream: true,
|
|
|
|
|
speed: parseFloat(this.elements.speedSlider.value)
|
2025-01-23 02:00:46 -07:00
|
|
|
|
}),
|
|
|
|
|
signal: this.currentController.signal
|
2025-01-22 21:11:47 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new Error(error.detail?.message || 'Failed to generate speech');
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
const chunks = [];
|
|
|
|
|
const reader = response.body.getReader();
|
|
|
|
|
let totalChunks = 0;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
while (true) {
|
|
|
|
|
const {value, done} = await reader.read();
|
|
|
|
|
|
|
|
|
|
if (done) {
|
|
|
|
|
this.showStatus('Processing complete', 'success');
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chunks.push(value);
|
|
|
|
|
this.audioChunks.push(value.slice(0));
|
|
|
|
|
totalChunks++;
|
|
|
|
|
|
|
|
|
|
if (totalChunks % 5 === 0) {
|
|
|
|
|
this.showStatus(`Received ${totalChunks} chunks...`, 'info');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const blob = new Blob(chunks, { type: 'audio/mpeg' });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
|
|
|
|
if (this.sound) {
|
|
|
|
|
this.sound.unload();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.sound = new Howl({
|
|
|
|
|
src: [url],
|
|
|
|
|
format: ['mp3'],
|
|
|
|
|
html5: true,
|
|
|
|
|
onplay: () => {
|
|
|
|
|
this.elements.playPauseBtn.textContent = 'Pause';
|
|
|
|
|
this.wave.start();
|
|
|
|
|
this.updateTimeDisplay();
|
|
|
|
|
},
|
|
|
|
|
onpause: () => {
|
|
|
|
|
this.elements.playPauseBtn.textContent = 'Play';
|
|
|
|
|
this.wave.stop();
|
|
|
|
|
},
|
|
|
|
|
onend: () => {
|
|
|
|
|
this.elements.playPauseBtn.textContent = 'Play';
|
|
|
|
|
this.wave.stop();
|
|
|
|
|
this.elements.generateBtn.disabled = false;
|
|
|
|
|
},
|
|
|
|
|
onload: () => {
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
this.showStatus('Audio ready', 'success');
|
|
|
|
|
this.enableDownload();
|
|
|
|
|
if (this.elements.autoplayToggle.checked) {
|
|
|
|
|
this.sound.play();
|
2025-01-22 21:11:47 -07:00
|
|
|
|
}
|
2025-01-23 02:00:46 -07:00
|
|
|
|
},
|
|
|
|
|
onloaderror: () => {
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
this.showStatus('Error loading audio', 'error');
|
2025-01-22 21:11:47 -07:00
|
|
|
|
}
|
|
|
|
|
});
|
2025-01-23 02:00:46 -07:00
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error.name === 'AbortError') {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
console.error('Streaming error:', error);
|
|
|
|
|
this.showStatus('Error during streaming', 'error');
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
2025-01-22 21:11:47 -07:00
|
|
|
|
}
|
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
enableDownload() {
|
|
|
|
|
this.elements.downloadBtn.style.display = 'flex';
|
|
|
|
|
}
|
2025-01-22 21:11:47 -07:00
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
downloadAudio() {
|
|
|
|
|
if (this.audioChunks.length === 0) return;
|
2025-01-22 21:11:47 -07:00
|
|
|
|
|
2025-01-23 02:00:46 -07:00
|
|
|
|
const format = this.elements.formatSelect.value;
|
2025-01-23 04:54:55 -07:00
|
|
|
|
const voice = Array.from(this.selectedVoiceSet).join('+');
|
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
2025-01-23 02:00:46 -07:00
|
|
|
|
const blob = new Blob(this.audioChunks, { type: `audio/${format}` });
|
2025-01-22 21:11:47 -07:00
|
|
|
|
const url = URL.createObjectURL(blob);
|
2025-01-23 02:00:46 -07:00
|
|
|
|
const a = document.createElement('a');
|
|
|
|
|
a.href = url;
|
2025-01-23 04:54:55 -07:00
|
|
|
|
a.download = `${voice}_${timestamp}.${format}`;
|
2025-01-23 02:00:46 -07:00
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
URL.revokeObjectURL(url);
|
2025-01-22 21:11:47 -07:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
new KokoroPlayer();
|
|
|
|
|
});
|