mitch donaberger 3 月之前
当前提交
64c3390b37
共有 10 个文件被更改,包括 767 次插入0 次删除
  1. 二进制
      .DS_Store
  2. 86 0
      README.md
  3. 83 0
      index.html
  4. 107 0
      readme/mixtape-maker-logo.svg
  5. 二进制
      readme/thumbnail.jpg
  6. 4 0
      reel-to-reel-animated.svg
  7. 0 0
      reel-to-reel-static.svg
  8. 290 0
      script.js
  9. 二进制
      stripesbg.png
  10. 197 0
      style.css

二进制
.DS_Store


+ 86 - 0
README.md

@@ -0,0 +1,86 @@
+I'll help create a beautiful GitHub README.md for your Mixtape Maker project! Let me analyze the files and create a comprehensive documentation. 🎯
+
+![Mixtape Maker Logo](readme/mixtape-maker-logo.svg)
+
+# 🎼 Mixtape Maker
+
+**A nostalgic web-based mixtape Maker that lets you create digital mixtapes with your MP3 files.** This project recreates the classic experience of making mixtapes, complete with reel-to-reel animation and precise timing controls.
+
+![Mixtape Maker Preview](readme/thumbnail.jpg)
+
+## ✨ Features
+
+- **📀 Drag-and-Drop Playlist Management**
+- **⏱️ Configurable Track Gaps** (customize silence between songs)
+- **📏 Tape Length Simulation** (set your virtual tape's capacity)
+- **🎚️ Real-time Volume Visualization**
+- **🎬 Animated Reel-to-Reel Display**
+- **📊 Progress Tracking** with remaining time display
+- **💾 Local-only Processing** - no server uploads
+- **🎨 Beautiful Catppuccin-inspired Dark Theme**
+
+## 🚀 Quick Start
+
+1. **Download the Project**
+   ```bash
+   git clone https://git.donaberger.xyz/mitch/mixtape-maker
+   # OR
+   # Download the ZIP file from Releases
+   ```
+
+2. **Launch the Application**
+   - Simply open `index.html` in your web browser.
+   - _No server required!_
+
+## 💡 How It Works
+
+1. **Upload MP3 Files**: Select your MP3 files using the file input
+2. **Configure Settings**:
+   - Set tape length (in minutes)
+   - Adjust gap between tracks (in seconds)
+3. **Arrange Playlist**: Drag and drop tracks to reorder
+4. **Record**: Click "Record to Tape" to start the process
+
+## ⚠️ Important Notes
+
+- **Privacy First**: All processing happens locally in your browser
+- **System Audio**: Records through your system's audio mixer
+- **Browser Support**: Works best in modern browsers with Web Audio API support
+- **File Types**: Currently supports MP3 format only
+
+## 🎨 Technical Details
+
+- **Frontend**: Pure HTML5, CSS3, and JavaScript
+- **Audio Processing**: Web Audio API
+- **Animations**: CSS Animations and SVG
+- **Theme**: Custom dark theme with nostalgic UI elements
+
+## 🛠️ Deployment Options
+
+1. **Local Usage**
+   - Download and open `index.html` directly
+
+2. **Web Server** (optional)
+   ```bash
+   # Using Python
+   python -m http.server 8000
+   
+   # Using Node.js
+   npx serve
+   ```
+
+## 🤝 Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request.
+
+## 📄 License
+
+This project is open source and available under the MIT License.
+
+## 🙏 Acknowledgments
+
+Created with ❤️ by Mitch Donaberger (_January 2025_)
+
+---
+
+_Note: This is a prototype created for nostalgic enjoyment. For best results, use a modern web browser with support for the `<audio>` tags._

+ 83 - 0
index.html

@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html lang="en" >
+<head>
+  <meta charset="UTF-8">
+  <title>mixtape recorder prototype</title>
+  <link rel="stylesheet" href="./style.css">
+
+  <!-- Open Graph Meta Tags -->
+  <meta property="og:title" content="Mixtape Recorder">
+  <meta property="og:description" content="Create your own mixtapes with this nostalgic mixtape recorder prototype.">
+  <meta property="og:image" content="thumbnail.jpg">
+  <meta property="og:url" content="https://donaberger.xyz/projects/mixtape/">
+  <meta property="og:type" content="website">
+
+  <!-- Twitter Card Meta Tags -->
+  <meta name="twitter:card" content="summary_large_image">
+  <meta name="twitter:title" content="Mixtape Recorder">
+  <meta name="twitter:description" content="Create your own mixtapes with this nostalgic mixtape recorder prototype.">
+  <meta name="twitter:image" content="thumbnail.jpg">
+
+  <!-- Additional Meta Tags -->
+  <meta name="description" content="A prototype for a mixtape recorder that allows you to record your own mixtapes to a reel-to-reel tape.">
+  <meta name="keywords" content="mixtape, recorder, prototype, reel-to-reel, nostalgic, music">
+  <meta name="author" content="Mitch Donaberger, January 2025">
+</head>
+<body>
+<!-- partial:index.partial.html -->
+<body>
+    <div class="container">
+        <h1>Mixtape Recorder</h1>
+
+        <div class="reel-animation">
+            <br/>
+            <img src="reel-to-reel-static.svg" alt="reel-spinner" class="reel-spinner" style="position: relative; left: 0rem;" />
+            <br/><br/>
+        </div>
+
+        <div class="controls">
+            MP3: <input type="file" id="mp3Files" multiple accept=".mp3" class="file-input"><br/>
+            Tape Length:<input type="number" id="tapeLength" placeholder="Tape Length (minutes)" class="tape-input"><br/>
+            Silence Gap:<input type="number" id="trackGap" placeholder="Seconds between tracks" class="tape-input" value="2" min="0" step="0.5">
+        </div>
+
+        <div id="playlist" class="playlist">
+            <h2>Playlist</h2>
+            <ul id="trackList" class="track-list" ondragover="event.preventDefault()">
+                <!-- tracks will be added here -->
+            </ul>
+        </div>
+
+        <div class="info">
+            <p>Total Duration: <span id="totalDuration">0</span> minutes</p>
+            <p>Remaining Time: <span id="remainingTime">0</span> minutes</p>
+        </div>
+
+        <button id="recordButton" class="record-button">Record to Tape</button>
+        <div id="progress" class="progress"></div>
+        
+
+        <div class="faq">
+            <H1>FAQ.</H1>
+            <p class="faq-question">Q: What is this?</p>
+            <p class="faq-answer">A: This is a prototype for a mixtape recorder. It allows you to record your own mixtapes to a reel-to-reel tape, like a Walkman.</p>
+
+            <p class="faq-question">Q: How does it work?</p>
+            <p class="faq-answer">A: You can upload your own MP3 files, as well as set the length of the tape and the gap between tracks. You can also drag and drop the tracks to sort the playlist before recording.</p>
+
+            <p class="faq-question">Q: Does any of my data get saved?</p>
+            <p class="faq-answer">A: No, all of your data is stored in your browser and is not saved anywhere on our servers.</p>
+
+            <p class="faq-question">Q: But... why?</p>
+            <p class="faq-answer">A: I have a fond memory of making my own mixtapes on a Walkman. I figured there has to be a way to build tapes using MP3s. I just wanted to make something fun and nostalgic, so, I hope this fits the bill.</p>
+
+        </div>
+    </div>
+
+    <script src="script.js"></script>
+</body>
+<!-- partial -->
+  <script  src="./script.js"></script>
+
+</body>
+</html>

文件差异内容过多而无法显示
+ 107 - 0
readme/mixtape-maker-logo.svg


二进制
readme/thumbnail.jpg


文件差异内容过多而无法显示
+ 4 - 0
reel-to-reel-animated.svg


文件差异内容过多而无法显示
+ 0 - 0
reel-to-reel-static.svg


+ 290 - 0
script.js

@@ -0,0 +1,290 @@
+const mp3Files = document.getElementById('mp3Files');
+const tapeLengthInput = document.getElementById('tapeLength');
+const trackList = document.getElementById('trackList');
+const totalDurationDisplay = document.getElementById('totalDuration');
+const remainingTimeDisplay = document.getElementById('remainingTime');
+const recordButton = document.getElementById('recordButton');
+const progress = document.getElementById('progress');
+const volumeIndicator = document.createElement('div');
+volumeIndicator.className = 'volume-indicator';
+document.body.appendChild(volumeIndicator);
+const reelImage = document.querySelector('.reel-spinner');
+const STATIC_REEL = 'reel-to-reel-static.svg';
+const ANIMATED_REEL = 'reel-to-reel-animated.svg';
+const trackGapInput = document.getElementById('trackGap');
+const body = document.body;
+
+let totalDuration = 0;
+let files = [];
+let audioContext;
+let analyser;
+
+mp3Files.addEventListener('change', handleFiles);
+tapeLengthInput.addEventListener('input', updateRemainingTime);
+recordButton.addEventListener('click', recordToTape);
+
+async function handleFiles(event) {
+    const newFiles = Array.from(event.target.files);
+    
+    for (const file of newFiles) {
+        files.push(file);
+        const duration = await getDuration(file);
+        totalDuration += duration;
+        addTrackToPlaylist(file.name, duration, files.length - 1);
+    }
+
+    updateTotalDuration();
+    updateRemainingTime();
+}
+
+async function getDuration(file) {
+    return new Promise((resolve) => {
+        const audio = new Audio();
+        audio.preload = 'metadata';
+        audio.src = URL.createObjectURL(file);
+        audio.onloadedmetadata = () => {
+            resolve(audio.duration / 60); // Duration in minutes
+            URL.revokeObjectURL(audio.src);
+        };
+    });
+}
+
+function addTrackToPlaylist(name, duration, index) {
+    const li = document.createElement('li');
+    li.draggable = true;
+    li.dataset.index = index;
+    li.textContent = `${name} (${duration.toFixed(2)} minutes)`;
+    
+    li.addEventListener('dragstart', handleDragStart);
+    li.addEventListener('dragover', handleDragOver);
+    li.addEventListener('drop', handleDrop);
+    li.addEventListener('dragend', handleDragEnd);
+    
+    trackList.appendChild(li);
+}
+
+function updateTotalDuration() {
+    totalDurationDisplay.textContent = totalDuration.toFixed(2);
+}
+
+function updateRemainingTime() {
+    const tapeLength = parseFloat(tapeLengthInput.value);
+    if (tapeLength) {
+        const remainingTime = tapeLength - totalDuration;
+        remainingTimeDisplay.textContent = remainingTime.toFixed(2);
+    }
+}
+
+// ... other parts of the script
+
+async function recordToTape() {
+    if (files.length === 0) {
+        alert('Please add MP3 files to the playlist.');
+        return;
+    }
+
+    audioContext = new (window.AudioContext || window.webkitAudioContext)();
+    analyser = audioContext.createAnalyser();
+    analyser.fftSize = 256;
+    analyser.connect(audioContext.destination);
+
+    updateVolumeIndicator();
+    startReelAnimation();
+    body.classList.add('recording-active');
+
+    console.log("Starting playback with order:");
+    logPlaylist();
+
+    const gapSeconds = parseFloat(trackGapInput.value) || 0;
+    
+    try {
+        for (let i = 0; i < files.length; i++) {
+            console.log(`Playing track ${i}: ${files[i].name}`);
+            const duration = await getDuration(files[i]);
+            await playTrack(files[i], duration);
+            
+            if (i < files.length - 1 && gapSeconds > 0) {
+                await addGapBetweenTracks(gapSeconds);
+            }
+        }
+    } catch (error) {
+        console.error("Playback error:", error);
+    }
+
+    stopReelAnimation();
+    body.classList.remove('recording-active');
+    alert('Recording completed!');
+}
+
+async function playTrack(file, duration) {
+    return new Promise((resolve) => {
+        const audio = new Audio();
+        audio.src = URL.createObjectURL(file);
+
+        const source = audioContext.createMediaElementSource(audio);
+        source.connect(analyser);
+
+        audio.currentTime = 0; // Always start from beginning
+        audio.play();
+
+        audio.onended = () => {
+            resolve();
+            URL.revokeObjectURL(audio.src);
+        };
+
+        audio.ontimeupdate = () => {
+            const currentPlayTime = audio.currentTime;
+            const progressPercent = (currentPlayTime / (totalDuration * 60)) * 100;
+            updateProgress(progressPercent);
+
+            const tapeLength = parseFloat(tapeLengthInput.value) * 60;
+            const remainingTime = tapeLength - currentPlayTime;
+            remainingTimeDisplay.textContent = (remainingTime / 60).toFixed(2);
+        };
+    });
+}
+
+// ... rest of the script
+
+
+function updateProgress(percent) {
+    progress.innerHTML = `<div class="progress-bar" style="width: ${percent}%;"></div>`;
+}
+
+function updateRemainingTimeDisplay(currentTime) {
+    const tapeLength = parseFloat(tapeLengthInput.value) * 60;
+    const remainingTime = tapeLength - currentTime;
+    remainingTimeDisplay.textContent = (remainingTime / 60).toFixed(2);
+}
+
+function updateVolumeIndicator() {
+    const dataArray = new Uint8Array(analyser.frequencyBinCount);
+    analyser.getByteFrequencyData(dataArray);
+    const averageVolume = dataArray.reduce((a, b) => a + b) / dataArray.length;
+    volumeIndicator.style.height = `${averageVolume / 255 * 100}px`; // Scale height based on volume
+
+    requestAnimationFrame(updateVolumeIndicator);
+}
+
+function startReelAnimation() {
+    reelImage.src = ANIMATED_REEL;
+}
+
+function stopReelAnimation() {
+    reelImage.src = STATIC_REEL;
+}
+
+function addGapBetweenTracks(seconds) {
+    return new Promise(resolve => {
+        console.log(`Adding ${seconds} second gap`);
+        const startTime = audioContext.currentTime;
+        
+        function checkGap() {
+            const elapsed = audioContext.currentTime - startTime;
+            if (elapsed >= seconds) {
+                resolve();
+            } else {
+                requestAnimationFrame(checkGap);
+            }
+        }
+        
+        checkGap();
+    });
+}
+
+let draggedItem = null;
+
+function handleDragStart(e) {
+    draggedItem = e.target;
+    e.target.style.opacity = '0.4';
+}
+
+function handleDragOver(e) {
+    e.preventDefault();
+    const targetItem = e.target;
+    
+    // Only handle drag over list items
+    if (targetItem.tagName === 'LI') {
+        const bounding = targetItem.getBoundingClientRect();
+        const offset = bounding.y + (bounding.height/2);
+        
+        if (e.clientY - offset > 0) {
+            targetItem.style.borderBottom = 'solid 2px #eed49f';
+            targetItem.style.borderTop = '';
+        } else {
+            targetItem.style.borderTop = 'solid 2px #eed49f';
+            targetItem.style.borderBottom = '';
+        }
+    }
+}
+
+function handleDrop(e) {
+    e.preventDefault();
+    const targetItem = e.target;
+    
+    // Only handle drops on list items
+    if (targetItem.tagName === 'LI' && draggedItem !== targetItem) {
+        // Get the current order of all items
+        const items = Array.from(trackList.children);
+        const oldIndex = items.indexOf(draggedItem);
+        
+        // Remove and insert the dragged item
+        draggedItem.parentNode.removeChild(draggedItem);
+        
+        // Determine if dropping before or after the target
+        const bounding = targetItem.getBoundingClientRect();
+        const insertAfter = e.clientY > (bounding.top + bounding.height / 2);
+        
+        if (insertAfter) {
+            targetItem.parentNode.insertBefore(draggedItem, targetItem.nextSibling);
+        } else {
+            targetItem.parentNode.insertBefore(draggedItem, targetItem);
+        }
+        
+        // Get the new order of items
+        const newItems = Array.from(trackList.children);
+        const newIndex = newItems.indexOf(draggedItem);
+        
+        // Reorder the files array to match
+        const [movedFile] = files.splice(oldIndex, 1);
+        files.splice(newIndex, 0, movedFile);
+        
+        // Update all indices
+        newItems.forEach((item, index) => {
+            item.dataset.index = index;
+        });
+        
+        // Log the new order for debugging
+        logPlaylist();
+    }
+    
+    clearDragOverStyles();
+}
+
+function handleDragEnd(e) {
+    e.target.style.opacity = '';
+    clearDragOverStyles();
+}
+
+function clearDragOverStyles() {
+    const items = trackList.querySelectorAll('li');
+    items.forEach(item => {
+        item.style.borderTop = '';
+        item.style.borderBottom = '';
+    });
+}
+
+function updatePlaylistIndices() {
+    const items = trackList.querySelectorAll('li');
+    items.forEach((item, index) => {
+        item.dataset.index = index;
+    });
+}
+
+// Add this function to help with debugging
+function logPlaylist() {
+    console.log("Current playlist order:");
+    files.forEach((file, index) => {
+        console.log(`${index}: ${file.name}`);
+    });
+}

二进制
stripesbg.png


+ 197 - 0
style.css

@@ -0,0 +1,197 @@
+body {
+    font-family: sans-serif;
+    background-color: #24273a;
+    margin: 0;
+    padding: 20px;
+    transition: background-image 0.3s ease;
+}
+
+body.recording-active {
+    background-image: url('stripesbg.png');
+    background-repeat: repeat;
+    background-size: 100px 100px;
+    animation: moveStripes 2s linear infinite;
+}
+
+.container {
+    max-width: 600px;
+    margin: 0 auto;
+    background-color: #363a4f;
+    padding: 20px;
+    border-radius: 8px;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+h1 {
+    text-align: center;
+    color: #eed49f;
+}
+
+.controls {
+    margin-bottom: 20px;
+    text-align: center;
+    color: #cad3f5;
+}
+
+.file-input, .tape-input {
+    padding: 10px;
+    margin: 10px;
+    border: 1px solid #eed49f;
+    border-radius: 4px;
+    color: #cad3f5;
+    background-color: #24273a;
+}
+
+.tape-input {
+  height: 1.6em;
+}
+
+.playlist {
+    border: 1px solid #eed49f;
+    border-radius: 4px;
+    padding: 15px;
+    margin-bottom: 20px;
+    color: #cad3f5;
+    background-color: #24273a;
+}
+
+.track-list {
+    list-style: none;
+    padding: 0;
+}
+
+.track-list li {
+    padding: 8px;
+    border-bottom: 1px solid #494d64;
+    cursor: move;
+    user-select: none;
+    transition: background-color 0.2s ease;
+}
+
+.track-list li:last-child {
+    border-bottom: none;
+}
+
+.track-list li:hover {
+    background-color: #363a4f;
+}
+
+.track-list li:active {
+    background-color: #494d64;
+}
+
+.info {
+    text-align: center;
+    margin-bottom: 20px;
+    color: #cad3f5;
+}
+
+.record-button {
+    background-color: #ed8796;
+    color: #24273a;
+    padding: 10px 20px;
+    border: none;
+    font-weight: bold;
+    border-radius: 8px; /* Slightly more rounded corners */
+    cursor: pointer;
+    display: block;
+    margin: 30px auto;
+    box-shadow: 
+        3px 3px 0 #d16573, /* Darker shade for the bottom-right shadow, creating depth */
+        -3px -3px 0 #ffa8b9; /* Lighter shade for the top-left highlight, adding a cel-shaded effect */
+    transform: translateY(-2px); /* Initial slight raise */
+    transition: all 0.2s ease; /* Smooth transition for hover and click */
+    position: relative; /* For positioning the click effect */
+    outline: none; /* Remove default focus outline */
+}
+
+.record-button:hover {
+    background-color: #f5a97f;
+    transform: translateY(0px); /* Press down on hover */
+    box-shadow: 
+        1px 1px 0 #d16573, /* Reduced shadow on hover */
+        -1px -1px 0 #ffa8b9; /* Reduced highlight on hover */
+}
+
+.record-button:active {
+    transform: translateY(2px); /* Clicked effect - further press down */
+    box-shadow: none; /* No shadow when clicked */
+}
+
+.record-button:active::after { /* Click animation */
+    content: "";
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    width: 0;
+    height: 0;
+    border-radius: 50%;
+    background-color: rgba(255, 255, 255, 0.3); /* Faint white circle for click effect */
+    transform: translate(-50%, -50%);
+    animation: clickEffect 0.3s ease-out;
+}
+
+@keyframes clickEffect {
+    0% {
+        width: 0;
+        height: 0;
+    }
+    50% {
+        width: 60%;
+        height: 60%;
+    }
+    100% {
+        width: 120%;
+        height: 120%;
+        opacity: 0;
+    }
+}
+
+.progress {
+    height: 20px;
+    background-color: #181926;
+    margin-top: 2em;
+    border-radius: 4px;
+    overflow: hidden;
+}
+
+.progress-bar {
+    height: 100%;
+    background: linear-gradient(90deg, #eed49f, #c6a0f6);
+    width: 0%;
+    transition: width 0.3s ease-in-out;
+}
+
+.volume-indicator {
+    position: fixed;
+    bottom: 10px;
+    right: 10px;
+    width: 20px;
+    height: 100px;
+    background: linear-gradient(0deg, #eed49f, #c6a0f6);
+    border: 1px solid #999;
+}
+
+@keyframes moveStripes {
+    0% {
+        background-position: 0 0;
+    }
+    100% {
+        background-position: 50px 50px;
+    }
+}
+
+.faq {
+    color: #cad3f5;
+    margin-top: 2em;
+}
+
+.faq-question {
+    font-weight: bold;
+    font-size: 1.2em;
+}
+
+.faq-answer {
+    margin-top: 0;
+    font-size: 1em;
+}

部分文件因为文件数量过多而无法显示