Pushing to GitHub after many changes.

This commit is contained in:
Chigozirim Igweamaka 2024-04-14 23:56:55 +01:00
parent 439b5442f5
commit 845f43b5bf
25 changed files with 19611 additions and 541 deletions

3
.gitignore vendored
View file

@ -3,6 +3,9 @@
# #
# Binaries for programs and plugins # Binaries for programs and plugins
*.exe *.exe
*.ogg
*.m4a
*.zip
*.exe~ *.exe~
*.dll *.dll
*.so *.so

18623
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,10 +6,18 @@
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"extendable-media-recorder": "^9.1.15",
"extendable-media-recorder-wav-encoder": "^7.0.109",
"framer-motion": "^11.0.25",
"react": "^18.2.0", "react": "^18.2.0",
"react-animated-numbers": "^0.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^5.0.1",
"react-scripts": "4.0.3", "react-scripts": "4.0.3",
"react-slick": "^0.30.2",
"react-toastify": "^8.1.0",
"simple-peer": "^9.11.1", "simple-peer": "^9.11.1",
"slick-carousel": "^1.8.1",
"socket.io-client": "^2.5.0", "socket.io-client": "^2.5.0",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
@ -25,18 +33,11 @@
"react-app/jest" "react-app/jest"
] ]
}, },
"browserslist": { "browserslist": [
"production": [
">0.2%", ">0.2%",
"not dead", "not dead",
"not op_mini all" "not op_mini all"
], ],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": { "devDependencies": {
"react-error-overlay": "^6.0.9" "react-error-overlay": "^6.0.9"
} }

View file

@ -1,7 +1,14 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import Peer from "simple-peer"; import Peer from "simple-peer";
import io, { managers } from "socket.io-client"; import io from "socket.io-client";
import Form from "./Form"; import Form from "./components/Form";
import Listen from "./components/Listen";
import CarouselSliders from "./components/CarouselSliders";
import AnimatedNumber from "react-animated-numbers";
import { FaMasksTheater, FaMicrophoneLines } from "react-icons/fa6";
import { LiaLaptopSolid } from "react-icons/lia";
import { ToastContainer, toast, Slide } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
// const socket = io.connect('http://localhost:5000/'); // const socket = io.connect('http://localhost:5000/');
var socket = io("http://localhost:5000/"); var socket = io("http://localhost:5000/");
@ -10,74 +17,135 @@ function App() {
const [offer, setOffer] = useState(); const [offer, setOffer] = useState();
const [stream, setStream] = useState(); const [stream, setStream] = useState();
const [matches, setMatches] = useState([]); const [matches, setMatches] = useState([]);
const [serverEngaged, setServerEngaged] = useState(false); const [totalSongs, setTotalSongs] = useState(10);
const [isListening, setisListening] = useState(false);
const [audioInput, setAudioInput] = useState("device");
const [peerConnection, setPeerConnection] = useState(); const [peerConnection, setPeerConnection] = useState();
const [serverEngaged, setServerEngaged] = useState(false);
function record() {
const mediaDevice =
audioInput == "device"
? navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices)
: navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
mediaDevice({ audio: true })
.then(function (stream) {
const audioTracks = stream.getAudioTracks();
const audioStream = new MediaStream(audioTracks);
for (const track of stream.getVideoTracks()) {
track.stop();
}
const mediaRecorder = new MediaRecorder(audioStream);
const chunks = [];
mediaRecorder.ondataavailable = function (e) {
chunks.push(e.data);
console.log("DataType: ", e.data);
// if (e.data.type.startsWith("audio")) {
// chunks.push(e.data);
// }
};
mediaRecorder.addEventListener("stop", () => {
const blob = new Blob(chunks, { type: "audio/mpeg" }); // Assuming MP3 format
// Convert Blob to byte array
const reader = new FileReader();
reader.readAsArrayBuffer(blob);
reader.onloadend = () => {
if (reader.result) {
var binary = "";
var bytes = new Uint8Array(reader.result);
var len = bytes.byteLength;
for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
// Convert byte array to base64
const base64data = btoa(binary);
console.log("Base64:", base64data);
// Send the base64 data to the backend (assuming socket.emit exists)
socket.emit("blob", base64data);
} else {
console.error("Error reading Blob as array buffer");
}
};
});
// Start recording
mediaRecorder.start();
// Stop recording after 5 seconds
setTimeout(function () {
mediaRecorder.stop();
}, 15000);
})
.catch(function (err) {
console.error("Error accessing media devices.", err);
});
}
function cleanUp() { function cleanUp() {
if (stream != null) { if (stream != null) {
console.log("Cleaning tracks");
stream.getTracks().forEach((track) => track.stop()); stream.getTracks().forEach((track) => track.stop());
} }
setOffer(null);
setStream(null); setStream(null);
setPeerConnection(null); setisListening(false);
setServerEngaged(false); console.log("Cleanup complete.");
} }
// Function to initiate the client peer function createPeerConnection() {
function initiateClientPeer(stream = null) {
const peer = new Peer({ const peer = new Peer({
initiator: true, initiator: true,
trickle: false, trickle: false,
stream: stream, stream: null,
}); });
let offerHasBeenSet = false; // Handle peer events:
peer.on("signal", (offerData) => {
peer.on("signal", (data) => {
if (!offerHasBeenSet) {
console.log("Setting Offer!"); console.log("Setting Offer!");
setOffer(JSON.stringify(data)); setOffer(JSON.stringify(offerData));
offerHasBeenSet = true; setPeerConnection(peer);
}
}); });
peer.on("close", () => { peer.on("close", () => {
cleanUp();
setServerEngaged(false);
console.log("CONNECTION CLOSED"); console.log("CONNECTION CLOSED");
}); });
peer.on("error", (err) => { peer.on("error", (err) => {
console.error("An error occurred:", err); console.error("An error occurred:", err);
}); });
setPeerConnection(peer);
} }
useEffect(() => { useEffect(() => {
if (offer) {
console.log("Offer updated:", offer); console.log("Offer updated:", offer);
let renegotiated = false;
if (offer && stream && !renegotiated) {
let offerEncoded = btoa(offer); let offerEncoded = btoa(offer);
socket.emit("engage", offerEncoded); socket.emit("engage", offerEncoded);
socket.on("serverEngaged", (answer) => { socket.on("serverEngaged", (answer) => {
console.log("ServerSDP: ", answer); console.log("ServerSDP: ", answer);
let decodedAnswer = atob(answer); let decodedAnswer = atob(answer);
if (!serverEngaged && !stream && !peerConnection.destroyed) {
peerConnection.signal(decodedAnswer); peerConnection.signal(decodedAnswer);
console.log("Engaging Server"); }
console.log("Engaged Server");
setServerEngaged(true); setServerEngaged(true);
renegotiated = true;
}); });
} }
}, [offer]); }, [offer]);
useEffect(() => { useEffect(() => {
initiateClientPeer(); socket.on("connect", () => {
}, []); createPeerConnection();
});
// socket.on("connect", () => {
// initiateClientPeer();
// });
socket.on("matches", (matches) => { socket.on("matches", (matches) => {
matches = JSON.parse(matches); matches = JSON.parse(matches);
@ -87,20 +155,26 @@ function App() {
} else { } else {
console.log("No Matches"); console.log("No Matches");
} }
cleanUp(); cleanUp();
}); });
socket.on("downloadStatus", (msg) => { socket.on("downloadStatus", (msg) => {
console.log("downloadStatus: ", msg); console.log("downloadStatus: ", msg);
msg = JSON.parse(msg);
const msgTypes = ["info", "success", "error"];
if (msg.type !== undefined && msgTypes.includes(msg.type)) {
toast[msg.type](() => <div>{msg.message}</div>);
} else {
toast(msg.message);
}
}); });
socket.on("albumStat", (msg) => { socket.on("totalSongs", (songsCount) => {
console.log("Album stat: ", msg); console.log("Total songs in DB: ", songsCount);
}); setTotalSongs(songsCount);
socket.on("playlistStat", (msg) => {
console.log("Playlist stat: ", msg);
}); });
}, []);
useEffect(() => { useEffect(() => {
const emitTotalSongs = () => { const emitTotalSongs = () => {
@ -112,84 +186,106 @@ function App() {
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, []); }, []);
socket.on("totalSongs", (totalSongs) => { function stopListening() {
console.log("Total songs in DB: ", totalSongs); console.log("Pause Clicked");
}); cleanUp();
peerConnection.destroy();
const streamAudio = () => { setTimeout(() => {
navigator.mediaDevices createPeerConnection();
.getDisplayMedia({ audio: true }) }, 3);
}
function startListening() {
const mediaDevice =
audioInput === "device"
? navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices)
: navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
mediaDevice({ audio: true })
.then((stream) => { .then((stream) => {
console.log("isListening: ", isListening);
peerConnection.addStream(stream); peerConnection.addStream(stream);
setisListening(true);
// Renegotiate
let initOfferEncoded = btoa(offer);
socket.emit("initOffer", initOfferEncoded);
socket.on("initAnswer", (answer) => {
let decodedAnswer = atob(answer);
peerConnection.signal(decodedAnswer);
console.log("Renogotiated");
});
// End of Renegotiation
peerConnection.on("signal", (data) => {
setOffer(JSON.stringify(data));
console.log("Offer should be reset");
});
setStream(stream); setStream(stream);
stream.getVideoTracks()[0].onended = stopListening;
stream.getAudioTracks()[0].onended = stopListening;
}) })
.catch((error) => { .catch((error) => {
console.error("Error accessing user media:", error); console.error("Error accessing user media:", error);
// Handle error
}); });
if (!offer || !peerConnection) {
// If offer is not set, create a new one
console.log("NO OFFER. CREATING OFFER");
initiateClientPeer(stream);
} }
const handleLaptopIconClick = () => {
console.log("Laptop icon clicked");
setAudioInput("device");
}; };
const disengageServer = () => { const handleMicrophoneIconClick = () => {
peerConnection.destroy(); console.log("Microphone icon clicked");
setAudioInput("mic");
}; };
return ( return (
<div className="App"> <div className="App">
<h1>New App</h1> <h1>New App</h1>
<div> <h4 style={{ display: "flex", justifyContent: "flex-end" }}>
{serverEngaged ? ( <AnimatedNumber
<button disabled={true}>Listening</button> includeComma
) : ( animateToNumber={totalSongs}
<button onClick={() => streamAudio()}>Listen</button> config={{ tension: 89, friction: 40 }}
)} animationType={"calm"}
{serverEngaged && ( />
<button onClick={() => disengageServer()}>Stop Listening</button> &nbsp;Songs
)} </h4>
<div className="listen">
<Listen
stopListening={stopListening}
disable={!serverEngaged}
startListening={startListening}
isListening={isListening}
/>
</div> </div>
<Form socket={socket} /> <div className="audio-input">
<div> <div
{matches.map((match, index) => { onClick={handleLaptopIconClick}
const [h, m, s] = match.timestamp.split(":"); className={
const timestamp = audioInput !== "device"
parseInt(h, 10) * 120 + parseInt(m, 10) * 60 + parseInt(s, 10); ? "audio-input-device"
: "audio-input-device active-audio-input"
return ( }
<iframe >
key={index} <LiaLaptopSolid style={{ height: 20, width: 20 }} />
width="460"
height="284"
src={`https://www.youtube.com/embed/${match.youtubeid}?start=${timestamp}`}
title={match.songname}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
);
})}
</div> </div>
<div
onClick={handleMicrophoneIconClick}
className={
audioInput !== "mic"
? "audio-input-mic"
: "audio-input-mic active-audio-input"
}
>
<FaMicrophoneLines style={{ height: 20, width: 20 }} />
</div>
</div>
<div className="youtube">
<CarouselSliders matches={matches} />
</div>
<Form socket={socket} toast={toast} />
<ToastContainer
position="top-center"
autoClose={5000}
hideProgressBar={false}
newestOnTop={false}
closeOnClick
rtl={false}
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
transition={Slide}
/>
</div> </div>
); );
} }

View file

@ -1,80 +0,0 @@
import React, { Component } from "react";
class Form extends Component {
initialState = { spotifyUrl: "" };
state = this.initialState;
handleChange = (event) => {
const { name, value } = event.target;
this.setState({ [name]: value });
};
submitForm = () => {
const { spotifyUrl } = this.state;
const { socket } = this.props;
if (this.spotifyURLisValid(spotifyUrl) === false) {
return;
}
socket.emit("newDownload", spotifyUrl);
console.log("newDownload: ", spotifyUrl);
this.setState(this.initialState);
};
spotifyURLisValid = (url) => {
if (url.length === 0) {
console.log("Spotify URL required");
return false;
}
const splitURL = url.split("/");
if (splitURL.length < 2) {
console.log("Invalid Spotify URL format");
return false;
}
let spotifyID = splitURL[splitURL.length - 1];
if (spotifyID.includes("?")) {
spotifyID = spotifyID.split("?")[0];
}
// Check if the Spotify ID is alphanumeric
if (!/^[a-zA-Z0-9]+$/.test(spotifyID)) {
console.log("Invalid Spotify ID format");
return false;
}
// Check if the Spotify ID is of expected length
if (spotifyID.length !== 22) {
console.log("Invalid Spotify ID length");
return false;
}
// Additional validation logic can be added here
return true;
};
render() {
const { spotifyUrl } = this.state;
return (
<form>
<label htmlFor="spotifyUrl">spotifyUrl</label>
<input
type="text"
name="spotifyUrl"
id="spotifyUrl"
value={spotifyUrl}
placeholder="https://open.spotify.com/.../..."
onChange={this.handleChange}
/>
<input type="button" value="Submit" onClick={this.submitForm} />
</form>
);
}
}
export default Form;

View file

@ -1,42 +0,0 @@
function TableHeader () {
return (
<thead>
<tr>
<th>Name</th>
<th>Job</th>
<th>Remove</th>
</tr>
</thead>
)
}
function TableBody (props) {
const rows = props.characterData.map((row, index) => {
return (
<tr key={index}>
<td>{row.name}</td>
<td>{row.job}</td>
<td>
<button onClick={() => props.removeCharacter(index)}>Delete</button>
</td>
</tr>
)
})
return <tbody>{rows}</tbody>
}
function Table (props) {
const { characterData, removeCharacter } = props
return (
<table>
<TableHeader />
<TableBody characterData={characterData} removeCharacter={removeCharacter} />
</table>
)
}
export default Table

View file

@ -0,0 +1,62 @@
import React from "react";
import styles from "./styles/CarouselSliders.module.css";
const CarouselSliders = (props) => {
const [activeIdx, setActiveIdx] = React.useState(0);
return (
<>
<div className={styles.CarouselSliders}>
{!props.matches.length ? null : (
<div className={styles.Slider}>
{props.matches.map((match, index) => {
const [h, m, s] = match.timestamp.split(":");
const timestamp =
parseInt(h, 10) * 360 + parseInt(m, 10) * 60 + parseInt(s, 10);
return (
<>
<div
key={index}
id={`slide-${index}`}
className={styles.SlideItem}
>
<iframe
className="iframe-youtube"
src={`https://www.youtube.com/embed/${match.youtubeid}?start=${timestamp}`}
title={match.songname}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>
</div>
</>
);
})}
</div>
)}
<div className={styles.Circles}>
{props.matches.map((_, index) => {
return (
<a
key={index}
className={
index !== activeIdx
? styles.Link
: `${styles.Link} ${styles.ActiveLink}`
}
href={`#slide-${index}`}
onClick={() => {
setActiveIdx(index);
}}
></a>
);
})}
</div>
</div>
</>
);
};
export default CarouselSliders;

View file

@ -0,0 +1,58 @@
import React, { useState } from "react";
import styles from "./styles/Form.module.css";
const Form = ({ socket }) => {
const initialState = { spotifyUrl: "" };
const [formState, setFormState] = useState(initialState);
const handleChange = (event) => {
const { name, value } = event.target;
setFormState({ ...formState, [name]: value });
};
const submitForm = () => {
const { spotifyUrl } = formState;
if (spotifyURLisValid(spotifyUrl) === false) {
return;
}
socket.emit("newDownload", spotifyUrl);
console.log("newDownload: ", spotifyUrl);
setFormState(initialState);
};
const spotifyURLisValid = (url) => {
if (url.length === 0) {
console.log("Spotify URL required");
return false;
}
const splitURL = url.split("/");
if (splitURL.length < 2) {
return false;
}
return true;
};
const { spotifyUrl } = formState;
return (
<form className={styles.Form}>
<div style={{ flexGrow: 1 }}>
<div>Download songs to the server</div>
<input
type="text"
name="spotifyUrl"
id="spotifyUrl"
value={spotifyUrl}
placeholder="https://open.spotify.com/.../..."
onChange={handleChange}
/>
</div>
<input type="button" value="Submit" onClick={submitForm} />
</form>
);
};
export default Form;

View file

@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import styles from "./styles/Listen.module.css";
const ListenButton = ({ isListening, onClick, disable }) => {
return (
<button
disabled={disable}
onClick={onClick}
className={
disable
? styles.ListenButton
: `${styles.ListenButton} ${styles.Enabled}`
}
>
{disable ? "Loading..." : isListening ? "Listening..." : "Listen"}
</button>
);
};
const Listen = ({ disable, startListening, stopListening, isListening }) => {
const [listen, setListen] = useState(false);
useEffect(() => {
if (isListening === false && listen === true) {
setListen(false);
}
}, [isListening]);
useEffect(() => {
if (listen) {
startListening();
} else {
if (isListening) {
stopListening();
}
}
}, [listen]);
const toggleListen = () => {
setListen(!listen);
};
return (
<>
<div
className={
isListening
? `${styles.CirlceItems} ${styles.Play}`
: `${styles.CirlceItems} ${styles.Pause}`
}
>
<div className={styles.CircleItem}></div>
<div className={styles.CircleItem}></div>
<div className={styles.CircleItem}></div>
<ListenButton
isListening={isListening}
onClick={toggleListen}
disable={disable}
/>
</div>
</>
);
};
export default Listen;

View file

@ -0,0 +1,93 @@
.CarouselSliders {
margin: 10px;
}
.Slider {
/* height: 90vmin; */
width: 100%;
/* background-color: #c1c1c1; */
display: flex;
overscroll-behavior-inline: contain;
scroll-snap-type: inline mandatory;
overflow-x: scroll;
scroll-behavior: smooth;
}
.Slider::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.Slider {
scrollbar-width: 12px;
padding-bottom: 6px;
}
.Slider::-webkit-scrollbar-thumb {
border-radius: 20px;
background-color: rgba(0, 0, 0, 0.2);
}
.Slider::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.1);
}
.SlideItem {
/* height: 100%;
width: 100%;
min-width: 90%; */
margin-left: 20px;
flex-grow: 1;
scroll-snap-align: center;
}
.SlideItem:nth-child(1) { background-color: rebeccapurple;}
.SlideItem:nth-child(2) { background-color: dodgerblue;}
.SlideItem:nth-child(3) { background-color: greenyellow;}
.iframe-youtube {
width: 420px;
height: 210px;
}
@media screen and (max-width: 500px) {
.iframe-youtube {
width: 420px;
height: 200px;
}
.Slider {
overflow-x: hidden;
}
.Circles {
display: flex;
align-items: center;
justify-content: center;
margin: 8px 0;
}
.Circles .Link {
height: 16px;
width: 16px;
margin: 0 3px;
display: inline-block;
border-radius: 50%;
background-color: #c1c1c1;
}
.Link.ActiveLink {
background-color: #6a0dad;
}
}
@media screen and (max-width: 600px) {
.iframe-youtube {
width: 820px;
height: 400px;
}
}
@media screen and (max-width: 700px) {
.iframe-youtube {
width: 420px;
height: 210px;
}
}

View file

@ -0,0 +1,46 @@
Form {
width: 80%;
margin: auto;
display: flex;
gap: 12px;
align-items: end;
justify-content: center;
}
@media screen and (max-width: 550px) {
Form {
width: 100%;
margin: auto;
display: flex;
gap: 12px;
align-items: end;
justify-content: center;
}
}
.FormStatus {
margin-bottom: 2px;
padding: 2px 5px;
background-color: #3498db;
border-radius: 2px;
animation: slideUp 0.8s forwards;
}
.FormStatus.NoMessage {
/* animation: slideDown 0.8s forwards; */
transform: translateX(-50%) translateY(100%);
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* .slideDown {
transform: translateX(-50%) translateY(100%);
} */

View file

@ -0,0 +1,80 @@
.CirlceItems {
width: 35vmin;
height: 35vmin;
position: relative;
margin: auto;
}
.CircleItem {
background-color: dodgerblue;
height: 100%;
width: 100%;
border-radius: 50%;
position: absolute;
opacity: 0;
}
.Play > .CircleItem {
animation: expandFadeOut 3s infinite ease-out;
}
.Pause > .CircleItem {
animation-play-state: paused;
/* transition: opacity 3s ease-out;
opacity: 0;
animation: expandFadeOut 3s ease-in-out;
animation-iteration-count: 1;
animation-fill-mode: forwards; */
}
.CircleItem:nth-child(1) { animation-delay: 1s; }
.CircleItem:nth-child(2) { animation-delay: 2s; }
.CircleItem:nth-child(3) { animation-delay: 3s; }
.ListenButton,
.PauseButton {
position: absolute;
left: 36%;
transform: translateX(-50%);
top: 50%;
transform: translateY(-50%);
display: flex;
justify-content: center;
align-items: center;
}
.ListenButton {
height: 30%;
width: 30%;
background-color: dodgerblue;
border-radius: 50%;
color: aliceblue;
font-size: 10px;
transition-duration: 300ms;
cursor: default;
}
.ListenButton:hover {
background-color: dodgerblue;;
}
.ListenButton.Enabled:hover {
scale: 1.1;
cursor: pointer;
background-color: #0366ee;;
}
@keyframes expandFadeOut {
0% {
opacity: 0.55;
transform: scale(0);
}
100% {
opacity: 0;
transform: scale(1);
}
}

View file

@ -27,6 +27,10 @@
/* 2 */ /* 2 */
} }
@media screen and (min-width: 768px) {
.iframe-youtube {
width: 460px;height: 284px;
}}
/* Sections /* Sections
========================================================================== */ ========================================================================== */
/** /**
@ -43,6 +47,40 @@ main {
display: block; display: block;
} }
.App {
width: 65%;
margin: auto;
}
.songs {
text-align: right;
}
.audio-input {
display: flex;
justify-content: center;
gap: 60px;
}
.audio-input-device {
padding: 5px;
cursor: pointer;
}
.audio-input-mic {
padding: 5px;
cursor: pointer;
}
.active-audio-input {
padding: 5px;
height: 30px;
width: 30px;
border-radius: 50%;
background-color: #c1c1c1;
}
/** /**
* Correct the font size and margin on `h1` elements within `section` and * Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari. * `article` contexts in Chrome, Firefox, and Safari.

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom/client'; import ReactDOM from "react-dom/client";
import './index.css'; import "./index.css";
import App from './App'; import App from "./App";
import reportWebVitals from './reportWebVitals'; import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById("root"));
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<App /> <App />

View file

@ -93,7 +93,7 @@ func MatchSampleAudio(track *webrtc.TrackRemote) (string, error) {
// sampleAudio = nil // sampleAudio = nil
if len(match) > 0 { if len(match) > 0 {
fmt.Println("FOUND A MATCH! - ", match) fmt.Println("FOUND A MATCH! - ", match)
return match, nil // return match, nil
} }
} }
case <-stop: case <-stop:

View file

@ -1,10 +1,13 @@
package main package main
import ( import (
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"song-recognition/shazam"
"song-recognition/signal" "song-recognition/signal"
"song-recognition/spotify" "song-recognition/spotify"
"song-recognition/utils" "song-recognition/utils"
@ -154,13 +157,17 @@ func main() {
return return
} }
err = spotify.DlSingleTrack(spotifyURL, tmpSongDir) totalDownloads, err := spotify.DlSingleTrack(spotifyURL, tmpSongDir)
if err != nil { if err != nil {
socket.Emit("downloadStatus", fmt.Sprintf("Failed to download '%s' by '%s'", trackInfo.Title, trackInfo.Artist)) socket.Emit("downloadStatus", fmt.Sprintf("Failed to download '%s' by '%s'", trackInfo.Title, trackInfo.Artist))
return return
} }
if totalDownloads != 1 {
socket.Emit("downloadStatus", fmt.Sprintf("'%s' by '%s' failed to download", trackInfo.Title, trackInfo.Artist))
} else {
socket.Emit("downloadStatus", fmt.Sprintf("'%s' by '%s' was downloaded", trackInfo.Title, trackInfo.Artist)) socket.Emit("downloadStatus", fmt.Sprintf("'%s' by '%s' was downloaded", trackInfo.Title, trackInfo.Artist))
}
} else { } else {
fmt.Println("=> Only Spotify Album/Playlist/Track URL's are supported.") fmt.Println("=> Only Spotify Album/Playlist/Track URL's are supported.")
@ -168,6 +175,45 @@ func main() {
} }
}) })
server.OnEvent("/", "blob", func(socket socketio.Conn, base64data string) {
// Decode base64 data
decodedData, err := base64.StdEncoding.DecodeString(base64data)
if err != nil {
fmt.Println("Error: Failed to decode base64 data:", err)
return
}
// Save the decoded data to a file
err = ioutil.WriteFile("recorded_audio.ogg", decodedData, 0644)
if err != nil {
fmt.Println("Error: Failed to write file to disk:", err)
return
}
fmt.Println("Audio saved successfully.")
matches, err := shazam.Match(decodedData)
if err != nil {
fmt.Println("Error: Failed to match:", err)
return
}
jsonData, err := json.Marshal(matches)
if len(matches) > 5 {
jsonData, err = json.Marshal(matches[:5])
}
if err != nil {
fmt.Println("Log error: ", err)
return
}
socket.Emit("matches", string(jsonData))
fmt.Println("BLOB: ", matches)
})
server.OnEvent("/", "engage", func(s socketio.Conn, encodedOffer string) { server.OnEvent("/", "engage", func(s socketio.Conn, encodedOffer string) {
log.Println("engage: ", encodedOffer) log.Println("engage: ", encodedOffer)
@ -180,7 +226,7 @@ func main() {
// Set a handler for when a new remote track starts, this handler saves buffers to disk as // Set a handler for when a new remote track starts, this handler saves buffers to disk as
// an Ogg file. // an Ogg file.
oggFile, err := oggwriter.New("output.ogg", 44100, 1) oggFile, err := oggwriter.New("output.ogg", 48000, 1)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -188,9 +234,9 @@ func main() {
peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
codec := track.Codec() codec := track.Codec()
if strings.EqualFold(codec.MimeType, webrtc.MimeTypeOpus) { if strings.EqualFold(codec.MimeType, webrtc.MimeTypeOpus) {
fmt.Println("Got Opus track, saving to disk as output.opus (44.1 kHz, 1 channel)") // fmt.Println("Got Opus track, saving to disk as output.opus (44.1 kHz, 1 channel)")
// signal.SaveToDisk(oggFile, track) // signal.SaveToDisk(oggFile, track)
// TODO turn match to json here
matches, err := signal.MatchSampleAudio(track) matches, err := signal.MatchSampleAudio(track)
if err != nil { if err != nil {
panic(err) panic(err)

View file

@ -38,7 +38,7 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
db, err := utils.NewDbClient() db, err := utils.NewDbClient()
if err != nil { if err != nil {
return nil, fmt.Errorf("error connecting to DB: %d", err) return nil, err
} }
defer db.Close() defer db.Close()
@ -47,7 +47,7 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
for _, chunkfgp := range chunkFingerprints { for _, chunkfgp := range chunkFingerprints {
listOfChunkTags, err := db.GetChunkTags(chunkfgp) listOfChunkTags, err := db.GetChunkTags(chunkfgp)
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting chunk data with fingerprint %d: %v", chunkfgp, err) return nil, err
} }
for _, chunkTag := range listOfChunkTags { for _, chunkTag := range listOfChunkTags {
@ -69,6 +69,21 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
matches := make(map[string][]int) matches := make(map[string][]int)
for songKey, timestamps := range songsTimestamps { for songKey, timestamps := range songsTimestamps {
timestampsInSeconds, err := timestampsInSeconds(timestamps)
if err != nil && err.Error() == "insufficient timestamps" {
continue
} else if err != nil {
return nil, err
}
maxPeak, differenceSum, err := getMaxPeak(timestampsInSeconds)
if err != nil {
return nil, err
}
fmt.Printf("%s MaxPeak: %v, DifferenceSum: %d\n", songKey, maxPeak, differenceSum)
fmt.Println("=====================================================\n")
differences, err := timeDifference(timestamps) differences, err := timeDifference(timestamps)
if err != nil && err.Error() == "insufficient timestamps" { if err != nil && err.Error() == "insufficient timestamps" {
continue continue
@ -76,7 +91,7 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
return nil, err return nil, err
} }
fmt.Printf("%s DIFFERENCES: %d\n", songKey, differences) // fmt.Printf("%s DIFFERENCES: %d\n", songKey, differences)
if len(differences) >= 2 { if len(differences) >= 2 {
matches[songKey] = differences matches[songKey] = differences
if len(differences) > maxMatchCount { if len(differences) > maxMatchCount {
@ -88,10 +103,10 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
sortedChunkTags := sortMatchesByTimeDifference(matches, chunkTags) sortedChunkTags := sortMatchesByTimeDifference(matches, chunkTags)
fmt.Println("SORTED CHUNK TAGS: ", sortedChunkTags) // fmt.Println("SORTED CHUNK TAGS: ", sortedChunkTags)
fmt.Println("MATCHES: ", matches) // fmt.Println("MATCHES: ", matches)
fmt.Println("MATCH: ", maxMatch) fmt.Println("MATCH: ", maxMatch)
fmt.Println() // fmt.Println()
return sortedChunkTags, nil return sortedChunkTags, nil
} }
@ -123,6 +138,120 @@ func sortMatchesByTimeDifference(matches map[string][]int, chunkTags map[string]
return sortedChunkTags return sortedChunkTags
} }
func timestampsInSeconds(timestamps []string) ([]int, error) {
layout := "15:04:05"
timestampsInSeconds := make([]int, len(timestamps))
for i, ts := range timestamps {
parsedTime, err := time.Parse(layout, ts)
if err != nil {
return nil, fmt.Errorf("error parsing timestamp %q: %w", ts, err)
}
hours := parsedTime.Hour()
minutes := parsedTime.Minute()
seconds := parsedTime.Second()
timestampsInSeconds[i] = (hours * 3600) + (minutes * 60) + seconds
}
return timestampsInSeconds, nil
}
// getMaxPeak identifies clusters of timestamps (peaks) within a sequence where the differences between adjacent timestamps
// are below a certain threshold. It returns the largest peak, the sum of differences within that peak, and an error if any.
func getMaxPeak(timestamps []int) ([]int, int, error) {
if len(timestamps) < 2 {
return nil, 0, fmt.Errorf("insufficient timestamps")
}
var peaks [][]int
maxDifference := 15
var cluster []int
// Iterate over timestamps to identify peaks
for i := 0; i < len(timestamps)-1; i++ {
minuend, subtrahend := timestamps[i], timestamps[i+1]
// Ensure timestamps are in ascending order
if minuend > subtrahend {
peaks = append(peaks, cluster)
cluster = nil
continue
}
difference := int(math.Abs(float64(minuend - subtrahend)))
// Check if the difference is within the maximum allowed difference
if len(cluster) == 0 && difference <= maxDifference {
cluster = append(cluster, minuend, subtrahend)
} else if difference <= maxDifference {
cluster = append(cluster, subtrahend)
} else if difference > maxDifference {
peaks = append(peaks, cluster)
cluster = nil
}
}
// Identify the largest peak(s)
largestPeak := [][]int{peaks[0]}
for _, peak := range peaks[1:] {
if len(peak) == len(largestPeak[0]) {
largestPeak = append(largestPeak, peak)
} else if len(peak) > len(largestPeak[0]) {
largestPeak = nil
largestPeak = append(largestPeak, peak)
}
}
// In the case where there are multiple largest peaks,
// identify and return the largest peak with the smallest sum of differences
if len(largestPeak) > 1 {
fmt.Println("Largest Peak > 1: ", largestPeak)
// Deduplicate largest peaks in order to get accurate sum of difference
var largestPeakDeDuplicated [][]int
for _, peak := range largestPeak {
largestPeakDeDuplicated = append(largestPeakDeDuplicated, deduplicate(peak))
}
fmt.Println("Largest Peak deduplicated: ", largestPeakDeDuplicated)
minDifferenceSum := math.Inf(1)
var peakWithMinDifferenceSum []int
for idx, peak := range largestPeakDeDuplicated {
if len(peak) <= 1 {
continue
}
differenceSum := 0.0
for i := len(peak) - 1; i >= 1; i-- {
differenceSum += math.Abs(float64(peak[i] - peak[i-1]))
}
if differenceSum < minDifferenceSum {
minDifferenceSum = differenceSum
fmt.Printf("%v vs %v\n", largestPeak[idx], peak)
peakWithMinDifferenceSum = largestPeak[idx]
}
}
// In the case where no peak with the min difference sum was identified,
// probably because they were all duplicates, return the first from the largestspeaks
if len(peakWithMinDifferenceSum) == 0 {
peakWithMinDifferenceSum = largestPeak[0]
}
return peakWithMinDifferenceSum, int(minDifferenceSum), nil
}
// Otherwise, return the largest peak
maxPeak := largestPeak[0]
differenceSum := 0
for i := len(maxPeak) - 1; i >= 1; i-- {
differenceSum += maxPeak[i] - maxPeak[i-1]
}
return maxPeak, differenceSum, nil
}
func timeDifference(timestamps []string) ([]int, error) { func timeDifference(timestamps []string) ([]int, error) {
if len(timestamps) < 2 { if len(timestamps) < 2 {
return nil, fmt.Errorf("insufficient timestamps") return nil, fmt.Errorf("insufficient timestamps")
@ -144,17 +273,26 @@ func timeDifference(timestamps []string) ([]int, error) {
// sort.Ints(timestampsInSeconds) // sort.Ints(timestampsInSeconds)
differences := []int{} differencesSet := map[int]struct{}{}
var differences []int
for i := len(timestampsInSeconds) - 1; i >= 1; i-- { for i := len(timestampsInSeconds) - 1; i >= 1; i-- {
difference := timestampsInSeconds[i] - timestampsInSeconds[i-1] difference := timestampsInSeconds[i] - timestampsInSeconds[i-1]
// maxSeconds = 15 // maxSeconds = 15
if difference > 0 && difference <= 15 { if difference > 0 && difference <= 15 {
differencesSet[difference] = struct{}{}
differences = append(differences, difference) differences = append(differences, difference)
} }
} }
return differences, nil differencesList := []int{}
if len(differencesSet) > 0 {
for k := range differencesSet {
differencesList = append(differencesList, k)
}
}
return timestampsInSeconds, nil
} }
// Chunkify divides the input audio signal into chunks and calculates the Short-Time Fourier Transform (STFT) for each chunk. // Chunkify divides the input audio signal into chunks and calculates the Short-Time Fourier Transform (STFT) for each chunk.
@ -204,7 +342,10 @@ func FingerprintChunks(chunks [][]complex128, chunkTag *ChunkTag) ([]int64, map[
if chunkTag != nil { if chunkTag != nil {
// bytesPerSecond = (samplingRate * bitDepth * channels) / 8 // bytesPerSecond = (samplingRate * bitDepth * channels) / 8
chunksPerSecond = (chunkSize - hopSize) / samplingRate chunksPerSecond = (chunkSize - hopSize) / samplingRate
chunksPerSecond = 9 chunksPerSecond = len(chunks)
fmt.Println("CHUNKS PER SECOND: ", chunksPerSecond)
chunksPerSecond = 3
fmt.Println("CHUNKS PER SECOND: ", chunksPerSecond) fmt.Println("CHUNKS PER SECOND: ", chunksPerSecond)
// if chunkSize == 4096 { // if chunkSize == 4096 {
// chunksPerSecond = 10 // chunksPerSecond = 10
@ -219,7 +360,7 @@ func FingerprintChunks(chunks [][]complex128, chunkTag *ChunkTag) ([]int64, map[
if chunkCount == chunksPerSecond { if chunkCount == chunksPerSecond {
chunkCount = 0 chunkCount = 0
chunkTime = chunkTime.Add(1 * time.Second) chunkTime = chunkTime.Add(1 * time.Second)
// fmt.Println(chunkTime.Format("15:04:05")) fmt.Println(chunkTime.Format("15:04:05"))
} }
} }

18
shazam/utils.go Normal file
View file

@ -0,0 +1,18 @@
package shazam
// deduplicate returns a list of unique integers from the given array.
// The order of the given array is not preserved in the result.
func deduplicate(array []int) []int {
uniqueMap := make(map[int]struct{})
for _, num := range array {
uniqueMap[num] = struct{}{}
}
var uniqueList []int
for num := range uniqueMap {
uniqueList = append(uniqueList, num)
}
return uniqueList
}

File diff suppressed because one or more lines are too long

View file

@ -13,7 +13,6 @@ import (
"time" "time"
"github.com/pion/interceptor" "github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/intervalpli"
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
@ -67,7 +66,7 @@ func SaveToBytes(track *webrtc.TrackRemote) ([]byte, error) {
func MatchSampleAudio(track *webrtc.TrackRemote) ([]primitive.M, error) { func MatchSampleAudio(track *webrtc.TrackRemote) ([]primitive.M, error) {
// Use time.After to stop after 15 seconds // Use time.After to stop after 15 seconds
stop := time.After(50 * time.Second) stop := time.After(20 * time.Second)
// Use a ticker to process sampleAudio every 2 seconds // Use a ticker to process sampleAudio every 2 seconds
ticker := time.NewTicker(2 * time.Second) ticker := time.NewTicker(2 * time.Second)
@ -90,6 +89,7 @@ func MatchSampleAudio(track *webrtc.TrackRemote) ([]primitive.M, error) {
// Reset sampleAudio for fresh input // Reset sampleAudio for fresh input
// sampleAudio = nil // sampleAudio = nil
// if len(matches) > 0 { // if len(matches) > 0 {
// fmt.Println("FOUND A MATCH! - ", matches) // fmt.Println("FOUND A MATCH! - ", matches)
// jsonData, err := json.Marshal(matches) // jsonData, err := json.Marshal(matches)
@ -132,7 +132,7 @@ func SetupWebRTC(encodedOffer string) *webrtc.PeerConnection {
// Setup the codecs you want to use. // Setup the codecs you want to use.
// We'll use Opus, but you can also define your own // We'll use Opus, but you can also define your own
if err := m.RegisterCodec(webrtc.RTPCodecParameters{ if err := m.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 44100, Channels: 1, SDPFmtpLine: "", RTCPFeedback: nil}, RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 1, SDPFmtpLine: "", RTCPFeedback: nil},
PayloadType: 111, PayloadType: 111,
}, webrtc.RTPCodecTypeAudio); err != nil { }, webrtc.RTPCodecTypeAudio); err != nil {
panic(err) panic(err)
@ -144,16 +144,16 @@ func SetupWebRTC(encodedOffer string) *webrtc.PeerConnection {
// Register a intervalpli factory // Register a intervalpli factory
// This interceptor sends a PLI every 3 seconds. A PLI causes a keyframe to be generated by the sender. // This interceptor sends a PLI every 3 seconds. A PLI causes a keyframe to be generated by the sender.
intervalPliFactory, err := intervalpli.NewReceiverInterceptor() // intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
if err != nil { // if err != nil {
panic(err) // panic(err)
} // }
i.Add(intervalPliFactory) // i.Add(intervalPliFactory)
// Use the default set of Interceptors // // Use the default set of Interceptors
if err = webrtc.RegisterDefaultInterceptors(m, i); err != nil { // if err = webrtc.RegisterDefaultInterceptors(m, i); err != nil {
panic(err) // panic(err)
} // }
// Create the API object with the MediaEngine // Create the API object with the MediaEngine
api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(i)) api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(i))

View file

@ -4,29 +4,27 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"song-recognition/shazam" "song-recognition/shazam"
"song-recognition/utils" "song-recognition/utils"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
// "song-recognition/youtube"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/kkdai/youtube/v2" "github.com/kkdai/youtube/v2"
) )
var yellow = color.New(color.FgYellow) var yellow = color.New(color.FgYellow)
func DlSingleTrack(url, savePath string) error { func DlSingleTrack(url, savePath string) (int, error) {
trackInfo, err := TrackInfo(url) trackInfo, err := TrackInfo(url)
if err != nil { if err != nil {
return err return 0, err
} }
fmt.Println("Getting track info...") fmt.Println("Getting track info...")
@ -34,12 +32,12 @@ func DlSingleTrack(url, savePath string) error {
track := []Track{*trackInfo} track := []Track{*trackInfo}
fmt.Println("Now, downloading track...") fmt.Println("Now, downloading track...")
_, err = dlTrack(track, savePath) totalTracksDownloaded, err := dlTrack(track, savePath)
if err != nil { if err != nil {
return err return 0, err
} }
return nil return totalTracksDownloaded, nil
} }
func DlPlaylist(url, savePath string) (int, error) { func DlPlaylist(url, savePath string) (int, error) {
@ -52,7 +50,6 @@ func DlPlaylist(url, savePath string) (int, error) {
fmt.Println("Now, downloading playlist...") fmt.Println("Now, downloading playlist...")
totalTracksDownloaded, err := dlTrack(tracks, savePath) totalTracksDownloaded, err := dlTrack(tracks, savePath)
if err != nil { if err != nil {
fmt.Println(err)
return 0, err return 0, err
} }
@ -83,6 +80,12 @@ func dlTrack(tracks []Track, path string) (int, error) {
numCPUs := runtime.NumCPU() numCPUs := runtime.NumCPU()
semaphore := make(chan struct{}, numCPUs) semaphore := make(chan struct{}, numCPUs)
db, err := utils.NewDbClient()
if err != nil {
return 0, err
}
defer db.Close()
for _, t := range tracks { for _, t := range tracks {
wg.Add(1) wg.Add(1)
go func(track Track) { go func(track Track) {
@ -100,30 +103,65 @@ func dlTrack(tracks []Track, path string) (int, error) {
Title: track.Title, Title: track.Title,
} }
// id1, err := VideoID(*trackCopy) // check if song exists
songExists, _ := db.SongExists(trackCopy.Title, trackCopy.Artist, "")
if songExists {
logMessage := fmt.Sprintf("'%s' by '%s' already downloaded\n", trackCopy.Title, trackCopy.Artist)
slog.Info(logMessage)
return
}
ytID, err := GetYoutubeId(*trackCopy) ytID, err := GetYoutubeId(*trackCopy)
if ytID == "" || err != nil { if ytID == "" || err != nil {
yellow.Printf("Error (1): '%s' by '%s' could not be downloaded\n", trackCopy.Title, trackCopy.Artist) logMessage := fmt.Sprintf("Error (0): '%s' by '%s' could not be downloaded: %s\n", trackCopy.Title, trackCopy.Artist, err)
slog.Error(logMessage)
yellow.Printf(logMessage)
return return
} }
trackCopy.Title, trackCopy.Artist = correctFilename(trackCopy.Title, trackCopy.Artist) // Check if YouTube ID exists
err = getAudio(ytID, path, trackCopy.Title, trackCopy.Artist) ytIdExists, _ := db.SongExists("", "", ytID)
if err != nil { if ytIdExists { // try to get the YouTube ID again
yellow.Printf("Error (2): '%s' by '%s' could not be downloaded: %s\n", trackCopy.Title, trackCopy.Artist, err) logMessage := fmt.Sprintf("YouTube ID exists. Trying again: %s\n", ytID)
slog.Warn(logMessage)
ytID, err := GetYoutubeId(*trackCopy)
if ytID == "" || err != nil {
logMessage := fmt.Sprintf("Error (1): '%s' by '%s' could not be downloaded: %s\n", trackCopy.Title, trackCopy.Artist, err)
slog.Info(logMessage)
yellow.Printf(logMessage)
return return
} }
// Process and save audio
filename := fmt.Sprintf("%s - %s.m4a", trackCopy.Title, trackCopy.Artist) ytIdExists, _ := db.SongExists("", "", ytID)
route := filepath.Join(path, filename) if ytIdExists {
err = processAndSaveSong(route, trackCopy.Title, trackCopy.Artist, ytID) logMessage := fmt.Sprintf("'%s' by '%s' could not be downloaded: YouTube ID (%s) exists\n", trackCopy.Title, trackCopy.Artist, ytID)
slog.Error(logMessage)
return
}
}
trackCopy.Title, trackCopy.Artist = correctFilename(trackCopy.Title, trackCopy.Artist)
fileName := fmt.Sprintf("%s - %s.m4a", trackCopy.Title, trackCopy.Artist)
filePath := filepath.Join(path, fileName)
err = getAudio(ytID, path, filePath)
if err != nil {
logMessage := fmt.Sprintf("Error (2): '%s' by '%s' could not be downloaded: %s\n", trackCopy.Title, trackCopy.Artist, err)
yellow.Printf(logMessage)
slog.Error(logMessage)
return
}
err = processAndSaveSong(filePath, trackCopy.Title, trackCopy.Artist, ytID)
if err != nil { if err != nil {
yellow.Println("Error processing audio: ", err) yellow.Println("Error processing audio: ", err)
logMessage := fmt.Sprintf("Failed to process song ('%s' by '%s'): %s\n", trackCopy.Title, trackCopy.Artist, err)
slog.Error(logMessage)
return
} }
trackCopy.Title, trackCopy.Artist = correctFilename(trackCopy.Title, trackCopy.Artist) // Consider removing this and deleting the song file after processing
filePath := fmt.Sprintf("%s%s - %s.m4a", path, trackCopy.Title, trackCopy.Artist)
if err := addTags(filePath, *trackCopy); err != nil { if err := addTags(filePath, *trackCopy); err != nil {
yellow.Println("Error adding tags: ", filePath) yellow.Println("Error adding tags: ", filePath)
return return
@ -155,7 +193,7 @@ func dlTrack(tracks []Track, path string) (int, error) {
} }
/* github.com/kkdai/youtube */ /* github.com/kkdai/youtube */
func getAudio(id, path, title, artist string) error { func getAudio(id, path, filePath string) error {
dir, err := os.Stat(path) dir, err := os.Stat(path)
if err != nil { if err != nil {
panic(err) panic(err)
@ -165,22 +203,6 @@ func getAudio(id, path, title, artist string) error {
return errors.New("the path is not valid (not a dir)") return errors.New("the path is not valid (not a dir)")
} }
db, err := utils.NewDbClient()
if err != nil {
return fmt.Errorf("error connecting to DB: %d", err)
}
defer db.Close()
// Check if the song has been processed and saved before
songKey := fmt.Sprintf("%s - %s", title, artist)
songExists, err := db.SongExists(songKey)
if err != nil {
return err
}
if songExists {
return fmt.Errorf("song exists")
}
client := youtube.Client{} client := youtube.Client{}
video, err := client.GetVideo(id) video, err := client.GetVideo(id)
if err != nil { if err != nil {
@ -191,15 +213,12 @@ func getAudio(id, path, title, artist string) error {
/* change the FindByItag parameter to 139 if you want smaller files (but with a bitrate of 48k) */ /* change the FindByItag parameter to 139 if you want smaller files (but with a bitrate of 48k) */
formats := video.Formats.Itag(140) formats := video.Formats.Itag(140)
filename := fmt.Sprintf("%s - %s.m4a", title, artist)
route := filepath.Join(path, filename)
/* in some cases, when attempting to download the audio /* in some cases, when attempting to download the audio
using the library github.com/kkdai/youtube, using the library github.com/kkdai/youtube,
the download fails (and shows the file size as 0 bytes) the download fails (and shows the file size as 0 bytes)
until the second or third attempt. */ until the second or third attempt. */
var fileSize int64 var fileSize int64
file, err := os.Create(route) file, err := os.Create(filePath)
if err != nil { if err != nil {
return err return err
} }
@ -214,46 +233,13 @@ func getAudio(id, path, title, artist string) error {
return err return err
} }
fileSize, _ = GetFileSize(route) fileSize, _ = GetFileSize(filePath)
} }
defer file.Close() defer file.Close()
return nil return nil
} }
func saveAudioToFile(audioReader io.Reader, path, title, artist string) error {
dir, err := os.Stat(path)
if err != nil {
panic(err)
}
if !dir.IsDir() {
return errors.New("the path is not valid (not a dir)")
}
filename := fmt.Sprintf("%s - %s.m4a", title, artist)
route := filepath.Join(path, filename)
/* in some cases, when attempting to download the audio
using the library github.com/kkdai/youtube,
the download fails (and shows the file size as 0 bytes)
until the second or third attempt. */
file, err := os.Create(route)
if err != nil {
return err
}
defer file.Close()
// Copy the audio stream to the file
_, err = io.Copy(file, audioReader)
if err != nil {
return err
}
return nil
}
func addTags(file string, track Track) error { func addTags(file string, track Track) error {
tempFile := file tempFile := file
index := strings.Index(file, ".m4a") index := strings.Index(file, ".m4a")
@ -292,60 +278,17 @@ func addTags(file string, track Track) error {
return nil return nil
} }
/* fixes some invalid file names (windows is the capricious one) */ func processAndSaveSong(songFilePath, songTitle, songArtist, ytID string) error {
func correctFilename(title, artist string) (string, string) {
if runtime.GOOS == "windows" {
invalidChars := []byte{'<', '>', '<', ':', '"', '\\', '/', '|', '?', '*'}
for _, invalidChar := range invalidChars {
title = strings.ReplaceAll(title, string(invalidChar), "")
artist = strings.ReplaceAll(artist, string(invalidChar), "")
}
} else {
title = strings.ReplaceAll(title, "/", "\\")
artist = strings.ReplaceAll(artist, "/", "\\")
}
return title, artist
}
func processAndSaveSong(m4aFile, songTitle, songArtist, ytID string) error {
db, err := utils.NewDbClient() db, err := utils.NewDbClient()
if err != nil { if err != nil {
return fmt.Errorf("error connecting to DB: %d", err) return err
} }
defer db.Close() defer db.Close()
// Check if the song has been processed and saved before audioBytes, err := convertStereoToMono(songFilePath)
songKey := fmt.Sprintf("%s - %s", songTitle, songArtist)
songExists, err := db.SongExists(songKey)
if err != nil { if err != nil {
return fmt.Errorf("error checking if song exists: %v", err) return fmt.Errorf("error converting song to mono: %v", err)
} }
if songExists {
fmt.Println("Song exists: ", songKey)
return nil
}
// Convert M4A file to mono
m4aFileMono := strings.TrimSuffix(m4aFile, filepath.Ext(m4aFile)) + "_mono.m4a"
// defer os.Remove(m4aFileMono)
audioBytes, err := ConvertM4aToMono(m4aFile, m4aFileMono)
if err != nil {
return fmt.Errorf("error converting M4A file to mono: %v", err)
}
// Run ffprobe to get metadata of the input file
cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=bit_depth,sample_rate", "-of", "default=noprint_wrappers=1:nokey=1", m4aFileMono)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("error running ffprobe: %v", err)
}
// Parse the output to extract bit depth and sampling rate
lines := strings.Split(string(output), "\n")
// bitDepth, _ := strconv.Atoi(strings.TrimSpace(lines[1]))
sampleRate, _ := strconv.Atoi(strings.TrimSpace(lines[0]))
fmt.Printf("SAMPLE RATE for %s: %v", songTitle, sampleRate)
chunkTag := shazam.ChunkTag{ chunkTag := shazam.ChunkTag{
SongTitle: songTitle, SongTitle: songTitle,
@ -353,24 +296,23 @@ func processAndSaveSong(m4aFile, songTitle, songArtist, ytID string) error {
YouTubeID: ytID, YouTubeID: ytID,
} }
// Calculate fingerprints // Fingerprint song
chunks := shazam.Chunkify(audioBytes) chunks := shazam.Chunkify(audioBytes)
_, fingerprints := shazam.FingerprintChunks(chunks, &chunkTag) _, fingerprints := shazam.FingerprintChunks(chunks, &chunkTag)
// Save fingerprints to MongoDB // Save fingerprints in DB
for fgp, ctag := range fingerprints { for fgp, ctag := range fingerprints {
err := db.InsertChunkTag(fgp, ctag) err := db.InsertChunkTag(fgp, ctag)
if err != nil { if err != nil {
return fmt.Errorf("error inserting document: %v", err) return err
} }
} }
// Save the song as processed err = db.RegisterSong(songTitle, songArtist, ytID)
err = db.RegisterSong(songKey)
if err != nil { if err != nil {
return err return err
} }
fmt.Println("Fingerprints saved to MongoDB successfully") fmt.Println("Fingerprints saved in MongoDB successfully")
return nil return nil
} }

View file

@ -99,7 +99,6 @@ func TrackInfo(url string) (*Track, error) {
endpoint := trackInitialPath + endpointQuery + "&extensions=" + EncodeParam(trackEndPath) endpoint := trackInitialPath + endpointQuery + "&extensions=" + EncodeParam(trackEndPath)
statusCode, jsonResponse, err := request(endpoint) statusCode, jsonResponse, err := request(endpoint)
// fmt.Print("TRACK INFO: ", jsonResponse)
if err != nil { if err != nil {
return nil, fmt.Errorf("error on getting track info: %w", err) return nil, fmt.Errorf("error on getting track info: %w", err)
} }
@ -108,12 +107,6 @@ func TrackInfo(url string) (*Track, error) {
return nil, fmt.Errorf("received non-200 status code: %d", statusCode) return nil, fmt.Errorf("received non-200 status code: %d", statusCode)
} }
// track := &Track{
// Title: gjson.Get(jsonResponse, "data.trackUnion.name").String(),
// Artist: gjson.Get(jsonResponse, "data.trackUnion.firstArtist.items.0.profile.name").String(),
// Album: gjson.Get(jsonResponse, "data.trackUnion.albumOfTrack.name").String(),
// }
var allArtists []string var allArtists []string
if firstArtist := gjson.Get(jsonResponse, "data.trackUnion.firstArtist.items.0.profile.name").String(); firstArtist != "" { if firstArtist := gjson.Get(jsonResponse, "data.trackUnion.firstArtist.items.0.profile.name").String(); firstArtist != "" {
@ -130,11 +123,14 @@ func TrackInfo(url string) (*Track, error) {
} }
} }
durationInSeconds := int(gjson.Get(jsonResponse, "data.trackUnion.duration.totalMilliseconds").Int())
durationInSeconds = durationInSeconds / 1000
track := &Track{ track := &Track{
Title: gjson.Get(jsonResponse, "data.trackUnion.name").String(), Title: gjson.Get(jsonResponse, "data.trackUnion.name").String(),
Artist: gjson.Get(jsonResponse, "data.trackUnion.firstArtist.items.0.profile.name").String(), Artist: gjson.Get(jsonResponse, "data.trackUnion.firstArtist.items.0.profile.name").String(),
Artists: allArtists, Artists: allArtists,
Duration: int(gjson.Get(jsonResponse, "data.trackUnion.duration.totalMilliseconds").Int()), Duration: durationInSeconds,
Album: gjson.Get(jsonResponse, "data.trackUnion.albumOfTrack.name").String(), Album: gjson.Get(jsonResponse, "data.trackUnion.albumOfTrack.name").String(),
} }
@ -256,17 +252,20 @@ func proccessItems(jsonResponse, resourceType string) []Track {
songTitle := map[bool]string{true: "itemV2.data.name", false: "track.name"}[resourceType == "playlist"] songTitle := map[bool]string{true: "itemV2.data.name", false: "track.name"}[resourceType == "playlist"]
artistName := map[bool]string{true: "itemV2.data.artists.items.0.profile.name", false: "track.artists.items.0.profile.name"}[resourceType == "playlist"] artistName := map[bool]string{true: "itemV2.data.artists.items.0.profile.name", false: "track.artists.items.0.profile.name"}[resourceType == "playlist"]
albumName := map[bool]string{true: "itemV2.data.albumOfTrack.name", false: "data.albumUnion.name"}[resourceType == "playlist"] albumName := map[bool]string{true: "itemV2.data.albumOfTrack.name", false: "data.albumUnion.name"}[resourceType == "playlist"]
duration := map[bool]string{true: "itemV2.data.trackDuration.totalMilliseconds", false: "track.duration.totalMilliseconds"}[resourceType == "playlist"]
var tracks []Track var tracks []Track
items := gjson.Get(jsonResponse, itemList).Array() items := gjson.Get(jsonResponse, itemList).Array()
for _, item := range items { for _, item := range items {
durationInSeconds := int(item.Get(duration).Int()) / 1000
track := &Track{ track := &Track{
Title: item.Get(songTitle).String(), Title: item.Get(songTitle).String(),
Artist: item.Get(artistName).String(), Artist: item.Get(artistName).String(),
Duration: durationInSeconds,
Album: map[bool]string{true: item.Get(albumName).String(), false: gjson.Get(jsonResponse, albumName).String()}[resourceType == "playlist"], Album: map[bool]string{true: item.Get(albumName).String(), false: gjson.Get(jsonResponse, albumName).String()}[resourceType == "playlist"],
} }
tracks = append(tracks, *track.buildTrack()) tracks = append(tracks, *track.buildTrack())
} }

View file

@ -6,6 +6,8 @@ import (
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"runtime"
"strings" "strings"
) )
@ -44,38 +46,51 @@ func DeleteFile(filePath string) {
} }
} }
// Convert M4A file from stereo to mono /* fixes some invalid file names (windows is the capricious one) */
func ConvertM4aToMono(inputFile, outputFile string) ([]byte, error) { func correctFilename(title, artist string) (string, string) {
cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "stream=channels", "-of", "default=noprint_wrappers=1:nokey=1", inputFile) if runtime.GOOS == "windows" {
invalidChars := []byte{'<', '>', '<', ':', '"', '\\', '/', '|', '?', '*'}
for _, invalidChar := range invalidChars {
title = strings.ReplaceAll(title, string(invalidChar), "")
artist = strings.ReplaceAll(artist, string(invalidChar), "")
}
} else {
title = strings.ReplaceAll(title, "/", "\\")
artist = strings.ReplaceAll(artist, "/", "\\")
}
return title, artist
}
func convertStereoToMono(stereoFilePath string) ([]byte, error) {
fileExt := filepath.Ext(stereoFilePath)
monoFilePath := strings.TrimSuffix(stereoFilePath, fileExt) + "_mono" + fileExt
defer os.Remove(monoFilePath)
// Check the number of channels in the stereo audio
cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "stream=channels", "-of", "default=noprint_wrappers=1:nokey=1", stereoFilePath)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return nil, fmt.Errorf("error running ffprobe: %v, %v", err, string(output)) return nil, fmt.Errorf("error getting number of channels: %v, %v", err, string(output))
} }
audioBytes, err := ioutil.ReadFile(inputFile)
if err != nil {
return nil, fmt.Errorf("error reading input file: %v", err)
}
channels := strings.TrimSpace(string(output)) channels := strings.TrimSpace(string(output))
if channels != "1" {
// Convert to mono audioBytes, err := ioutil.ReadFile(stereoFilePath)
cmd = exec.Command("ffmpeg", "-i", inputFile, "-af", "pan=mono|c0=c0", outputFile) if err != nil {
if err := cmd.Run(); err != nil { return nil, fmt.Errorf("error reading stereo file: %v", err)
return nil, fmt.Errorf("error running ffmpeg: %v", err)
} }
// Resample to 8192 Hz if channels != "1" {
// resampledFile := strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + "_resampled.m4a" // Convert stereo to mono and downsample by 44100/2
// cmd = exec.Command("ffmpeg", "-i", outputFile, "-ar", "8192", resampledFile) cmd = exec.Command("ffmpeg", "-i", stereoFilePath, "-af", "pan=mono|c0=c0", monoFilePath)
// output, err = cmd.CombinedOutput() // cmd = exec.Command("ffmpeg", "-i", stereoFilePath, "-af", "pan=mono|c0=c0", "-ar", "22050", monoFilePath)
// if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
// return nil, fmt.Errorf("error resampling: %v, %v", err, string(output)) return nil, fmt.Errorf("error converting stereo to mono: %v", err)
// } }
audioBytes, err = ioutil.ReadFile(outputFile) audioBytes, err = ioutil.ReadFile(monoFilePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("error reading input file: %v", err) return nil, fmt.Errorf("error reading mono file: %v", err)
} }
} }

View file

@ -78,7 +78,7 @@ func convertStringDurationToSeconds(durationStr string) int {
// GetYoutubeId takes the query as string and returns the search results video ID's // GetYoutubeId takes the query as string and returns the search results video ID's
func GetYoutubeId(track Track) (string, error) { func GetYoutubeId(track Track) (string, error) {
songDurationInSeconds := track.Duration * 60 songDurationInSeconds := track.Duration
searchQuery := fmt.Sprintf("'%s' %s %s", track.Title, track.Artist, track.Album) searchQuery := fmt.Sprintf("'%s' %s %s", track.Title, track.Artist, track.Album)
searchResults, err := ytSearch(searchQuery, 10) searchResults, err := ytSearch(searchQuery, 10)
@ -99,8 +99,7 @@ func GetYoutubeId(track Track) (string, error) {
} }
} }
// Else return the first result if nothing is found return "", fmt.Errorf("could not settle on a song from search result for: %s", searchQuery)
return searchResults[0].ID, nil
} }
func getContent(data []byte, index int) []byte { func getContent(data []byte, index int) []byte {
@ -114,6 +113,8 @@ func ytSearch(searchTerm string, limit int) (results []*SearchResult, err error)
"https://www.youtube.com/results?search_query=%s", url.QueryEscape(searchTerm), "https://www.youtube.com/results?search_query=%s", url.QueryEscape(searchTerm),
) )
// fmt.Println("Search URL: ", ytSearchUrl)
req, err := http.NewRequest("GET", ytSearchUrl, nil) req, err := http.NewRequest("GET", ytSearchUrl, nil)
if err != nil { if err != nil {
return nil, errors.New("cannot get youtube page") return nil, errors.New("cannot get youtube page")

View file

@ -22,7 +22,7 @@ func NewDbClient() (*DbClient, error) {
clientOptions := options.Client().ApplyURI(dbUri) clientOptions := options.Client().ApplyURI(dbUri)
client, err := mongo.Connect(context.Background(), clientOptions) client, err := mongo.Connect(context.Background(), clientOptions)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("error connecting to MongoDB: %d", err)
} }
return &DbClient{client: client}, nil return &DbClient{client: client}, nil
} }
@ -45,25 +45,50 @@ func (db *DbClient) TotalSongs() (int, error) {
return int(total), nil return int(total), nil
} }
func (db *DbClient) SongExists(key string) (bool, error) { func (db *DbClient) SongExists(songTitle, songArtist, ytID string) (bool, error) {
existingSongsCollection := db.client.Database("song-recognition").Collection("existing-songs") existingSongsCollection := db.client.Database("song-recognition").Collection("existing-songs")
filter := bson.M{"_id": key}
key := fmt.Sprintf("%s - %s", songTitle, songArtist)
var filter bson.M
if len(ytID) == 0 {
filter = bson.M{"_id": key}
} else {
filter = bson.M{"ytID": ytID}
}
var result bson.M var result bson.M
if err := existingSongsCollection.FindOne(context.Background(), filter).Decode(&result); err == nil { if err := existingSongsCollection.FindOne(context.Background(), filter).Decode(&result); err == nil {
return true, nil return true, nil
} else if err != mongo.ErrNoDocuments { } else if err != mongo.ErrNoDocuments {
return false, fmt.Errorf("error querying registered songs: %v", err) return false, fmt.Errorf("failed to retrieve registered songs: %v", err)
} }
return false, nil return false, nil
} }
func (db *DbClient) RegisterSong(key string) error { func (db *DbClient) RegisterSong(songTitle, songArtist, ytID string) error {
existingSongsCollection := db.client.Database("song-recognition").Collection("existing-songs") existingSongsCollection := db.client.Database("song-recognition").Collection("existing-songs")
_, err := existingSongsCollection.InsertOne(context.Background(), bson.M{"_id": key})
// Create a compound unique index on ytID and key, if it doesn't already exist
indexModel := mongo.IndexModel{
Keys: bson.D{{"ytID", 1}, {"key", 1}},
Options: options.Index().SetUnique(true),
}
_, err := existingSongsCollection.Indexes().CreateOne(context.Background(), indexModel)
if err != nil { if err != nil {
return fmt.Errorf("error registering song: %v", err) return fmt.Errorf("failed to create unique index: %v", err)
}
// Attempt to insert the song with ytID and key
key := fmt.Sprintf("%s - %s", songTitle, songArtist)
_, err = existingSongsCollection.InsertOne(context.Background(), bson.M{"_id": key, "ytID": ytID})
if err != nil {
if mongo.IsDuplicateKeyError(err) {
return fmt.Errorf("song with ytID or key already exists: %v", err)
} else {
return fmt.Errorf("failed to register song: %v", err)
}
} }
return nil return nil
@ -82,7 +107,7 @@ func (db *DbClient) InsertChunkTag(chunkfgp int64, chunkTag interface{}) error {
update := bson.M{"$push": bson.M{"chunkTags": chunkTag}} update := bson.M{"$push": bson.M{"chunkTags": chunkTag}}
_, err := chunksCollection.UpdateOne(context.Background(), filter, update) _, err := chunksCollection.UpdateOne(context.Background(), filter, update)
if err != nil { if err != nil {
return fmt.Errorf("error updating chunk data: %v", err) return fmt.Errorf("failed to update chunkTags: %v", err)
} }
return nil return nil
} else if err != mongo.ErrNoDocuments { } else if err != mongo.ErrNoDocuments {
@ -92,7 +117,7 @@ func (db *DbClient) InsertChunkTag(chunkfgp int64, chunkTag interface{}) error {
// If the document doesn't exist, insert a new document // If the document doesn't exist, insert a new document
_, err = chunksCollection.InsertOne(context.Background(), bson.M{"fingerprint": chunkfgp, "chunkTags": []interface{}{chunkTag}}) _, err = chunksCollection.InsertOne(context.Background(), bson.M{"fingerprint": chunkfgp, "chunkTags": []interface{}{chunkTag}})
if err != nil { if err != nil {
return fmt.Errorf("error inserting chunk data: %v", err) return fmt.Errorf("failed to insert chunk tag: %v", err)
} }
return nil return nil
@ -109,7 +134,7 @@ func (db *DbClient) GetChunkTags(chunkfgp int64) ([]primitive.M, error) {
if err == mongo.ErrNoDocuments { if err == mongo.ErrNoDocuments {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("error retrieving chunk data: %w", err) return nil, fmt.Errorf("failed to retrieve chunk tag: %w", err)
} }
var listOfChunkTags []primitive.M var listOfChunkTags []primitive.M
@ -137,7 +162,7 @@ func (db *DbClient) GetChunkTagForSong(songTitle, songArtist string) (bson.M, er
if err == mongo.ErrNoDocuments { if err == mongo.ErrNoDocuments {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("error finding chunk: %v", err) return nil, fmt.Errorf("failed to find chunk: %v", err)
} }
var chunkTag map[string]interface{} var chunkTag map[string]interface{}