mirror of
https://github.com/cgzirim/seek-tune.git
synced 2025-12-17 00:44:19 +00:00
Pushing to GitHub after many changes.
This commit is contained in:
parent
439b5442f5
commit
845f43b5bf
25 changed files with 19611 additions and 541 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,6 +3,9 @@
|
|||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.ogg
|
||||
*.m4a
|
||||
*.zip
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
|
|
|
|||
18623
client/package-lock.json
generated
18623
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,10 +6,18 @@
|
|||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.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-animated-numbers": "^0.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-scripts": "4.0.3",
|
||||
"react-slick": "^0.30.2",
|
||||
"react-toastify": "^8.1.0",
|
||||
"simple-peer": "^9.11.1",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"socket.io-client": "^2.5.0",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
|
|
@ -25,18 +33,11 @@
|
|||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-error-overlay": "^6.0.9"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import Peer from "simple-peer";
|
||||
import io, { managers } from "socket.io-client";
|
||||
import Form from "./Form";
|
||||
import io from "socket.io-client";
|
||||
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/');
|
||||
var socket = io("http://localhost:5000/");
|
||||
|
|
@ -10,74 +17,135 @@ function App() {
|
|||
const [offer, setOffer] = useState();
|
||||
const [stream, setStream] = 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 [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() {
|
||||
if (stream != null) {
|
||||
console.log("Cleaning tracks");
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
setOffer(null);
|
||||
setStream(null);
|
||||
setPeerConnection(null);
|
||||
setServerEngaged(false);
|
||||
setisListening(false);
|
||||
console.log("Cleanup complete.");
|
||||
}
|
||||
|
||||
// Function to initiate the client peer
|
||||
function initiateClientPeer(stream = null) {
|
||||
function createPeerConnection() {
|
||||
const peer = new Peer({
|
||||
initiator: true,
|
||||
trickle: false,
|
||||
stream: stream,
|
||||
stream: null,
|
||||
});
|
||||
|
||||
let offerHasBeenSet = false;
|
||||
|
||||
peer.on("signal", (data) => {
|
||||
if (!offerHasBeenSet) {
|
||||
// Handle peer events:
|
||||
peer.on("signal", (offerData) => {
|
||||
console.log("Setting Offer!");
|
||||
setOffer(JSON.stringify(data));
|
||||
offerHasBeenSet = true;
|
||||
}
|
||||
setOffer(JSON.stringify(offerData));
|
||||
setPeerConnection(peer);
|
||||
});
|
||||
|
||||
peer.on("close", () => {
|
||||
cleanUp();
|
||||
setServerEngaged(false);
|
||||
console.log("CONNECTION CLOSED");
|
||||
});
|
||||
|
||||
peer.on("error", (err) => {
|
||||
console.error("An error occurred:", err);
|
||||
});
|
||||
|
||||
setPeerConnection(peer);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (offer) {
|
||||
console.log("Offer updated:", offer);
|
||||
let renegotiated = false;
|
||||
|
||||
if (offer && stream && !renegotiated) {
|
||||
let offerEncoded = btoa(offer);
|
||||
socket.emit("engage", offerEncoded);
|
||||
|
||||
socket.on("serverEngaged", (answer) => {
|
||||
console.log("ServerSDP: ", answer);
|
||||
let decodedAnswer = atob(answer);
|
||||
if (!serverEngaged && !stream && !peerConnection.destroyed) {
|
||||
peerConnection.signal(decodedAnswer);
|
||||
console.log("Engaging Server");
|
||||
}
|
||||
console.log("Engaged Server");
|
||||
setServerEngaged(true);
|
||||
renegotiated = true;
|
||||
});
|
||||
}
|
||||
}, [offer]);
|
||||
|
||||
useEffect(() => {
|
||||
initiateClientPeer();
|
||||
}, []);
|
||||
|
||||
// socket.on("connect", () => {
|
||||
// initiateClientPeer();
|
||||
// });
|
||||
socket.on("connect", () => {
|
||||
createPeerConnection();
|
||||
});
|
||||
|
||||
socket.on("matches", (matches) => {
|
||||
matches = JSON.parse(matches);
|
||||
|
|
@ -87,20 +155,26 @@ function App() {
|
|||
} else {
|
||||
console.log("No Matches");
|
||||
}
|
||||
|
||||
cleanUp();
|
||||
});
|
||||
|
||||
socket.on("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) => {
|
||||
console.log("Album stat: ", msg);
|
||||
});
|
||||
|
||||
socket.on("playlistStat", (msg) => {
|
||||
console.log("Playlist stat: ", msg);
|
||||
socket.on("totalSongs", (songsCount) => {
|
||||
console.log("Total songs in DB: ", songsCount);
|
||||
setTotalSongs(songsCount);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const emitTotalSongs = () => {
|
||||
|
|
@ -112,84 +186,106 @@ function App() {
|
|||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
socket.on("totalSongs", (totalSongs) => {
|
||||
console.log("Total songs in DB: ", totalSongs);
|
||||
});
|
||||
function stopListening() {
|
||||
console.log("Pause Clicked");
|
||||
cleanUp();
|
||||
peerConnection.destroy();
|
||||
|
||||
const streamAudio = () => {
|
||||
navigator.mediaDevices
|
||||
.getDisplayMedia({ audio: true })
|
||||
setTimeout(() => {
|
||||
createPeerConnection();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
function startListening() {
|
||||
const mediaDevice =
|
||||
audioInput === "device"
|
||||
? navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices)
|
||||
: navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
||||
|
||||
mediaDevice({ audio: true })
|
||||
.then((stream) => {
|
||||
console.log("isListening: ", isListening);
|
||||
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);
|
||||
stream.getVideoTracks()[0].onended = stopListening;
|
||||
stream.getAudioTracks()[0].onended = stopListening;
|
||||
})
|
||||
.catch((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 = () => {
|
||||
peerConnection.destroy();
|
||||
const handleMicrophoneIconClick = () => {
|
||||
console.log("Microphone icon clicked");
|
||||
setAudioInput("mic");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<h1>New App</h1>
|
||||
<div>
|
||||
{serverEngaged ? (
|
||||
<button disabled={true}>Listening</button>
|
||||
) : (
|
||||
<button onClick={() => streamAudio()}>Listen</button>
|
||||
)}
|
||||
{serverEngaged && (
|
||||
<button onClick={() => disengageServer()}>Stop Listening</button>
|
||||
)}
|
||||
<h4 style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<AnimatedNumber
|
||||
includeComma
|
||||
animateToNumber={totalSongs}
|
||||
config={{ tension: 89, friction: 40 }}
|
||||
animationType={"calm"}
|
||||
/>
|
||||
Songs
|
||||
</h4>
|
||||
<div className="listen">
|
||||
<Listen
|
||||
stopListening={stopListening}
|
||||
disable={!serverEngaged}
|
||||
startListening={startListening}
|
||||
isListening={isListening}
|
||||
/>
|
||||
</div>
|
||||
<Form socket={socket} />
|
||||
<div>
|
||||
{matches.map((match, index) => {
|
||||
const [h, m, s] = match.timestamp.split(":");
|
||||
const timestamp =
|
||||
parseInt(h, 10) * 120 + parseInt(m, 10) * 60 + parseInt(s, 10);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={index}
|
||||
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 className="audio-input">
|
||||
<div
|
||||
onClick={handleLaptopIconClick}
|
||||
className={
|
||||
audioInput !== "device"
|
||||
? "audio-input-device"
|
||||
: "audio-input-device active-audio-input"
|
||||
}
|
||||
>
|
||||
<LiaLaptopSolid style={{ height: 20, width: 20 }} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
62
client/src/components/CarouselSliders.js
Normal file
62
client/src/components/CarouselSliders.js
Normal 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;
|
||||
58
client/src/components/Form.js
Normal file
58
client/src/components/Form.js
Normal 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;
|
||||
67
client/src/components/Listen.js
Normal file
67
client/src/components/Listen.js
Normal 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;
|
||||
93
client/src/components/styles/CarouselSliders.module.css
Normal file
93
client/src/components/styles/CarouselSliders.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
46
client/src/components/styles/Form.module.css
Normal file
46
client/src/components/styles/Form.module.css
Normal 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%);
|
||||
} */
|
||||
80
client/src/components/styles/Listen.module.css
Normal file
80
client/src/components/styles/Listen.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,10 @@
|
|||
/* 2 */
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.iframe-youtube {
|
||||
width: 460px;height: 284px;
|
||||
}}
|
||||
/* Sections
|
||||
========================================================================== */
|
||||
/**
|
||||
|
|
@ -43,6 +47,40 @@ main {
|
|||
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
|
||||
* `article` contexts in Chrome, Firefox, and Safari.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -93,7 +93,7 @@ func MatchSampleAudio(track *webrtc.TrackRemote) (string, error) {
|
|||
// sampleAudio = nil
|
||||
if len(match) > 0 {
|
||||
fmt.Println("FOUND A MATCH! - ", match)
|
||||
return match, nil
|
||||
// return match, nil
|
||||
}
|
||||
}
|
||||
case <-stop:
|
||||
|
|
|
|||
54
server.go
54
server.go
|
|
@ -1,10 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"song-recognition/shazam"
|
||||
"song-recognition/signal"
|
||||
"song-recognition/spotify"
|
||||
"song-recognition/utils"
|
||||
|
|
@ -154,13 +157,17 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
err = spotify.DlSingleTrack(spotifyURL, tmpSongDir)
|
||||
totalDownloads, err := spotify.DlSingleTrack(spotifyURL, tmpSongDir)
|
||||
if err != nil {
|
||||
socket.Emit("downloadStatus", fmt.Sprintf("Failed to download '%s' by '%s'", trackInfo.Title, trackInfo.Artist))
|
||||
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))
|
||||
}
|
||||
|
||||
} else {
|
||||
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) {
|
||||
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
|
||||
// an Ogg file.
|
||||
oggFile, err := oggwriter.New("output.ogg", 44100, 1)
|
||||
oggFile, err := oggwriter.New("output.ogg", 48000, 1)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
@ -188,9 +234,9 @@ func main() {
|
|||
peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
codec := track.Codec()
|
||||
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)
|
||||
// TODO turn match to json here
|
||||
|
||||
matches, err := signal.MatchSampleAudio(track)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
|
|
|||
161
shazam/shazam.go
161
shazam/shazam.go
|
|
@ -38,7 +38,7 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
|
|||
|
||||
db, err := utils.NewDbClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to DB: %d", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
|
|||
for _, chunkfgp := range chunkFingerprints {
|
||||
listOfChunkTags, err := db.GetChunkTags(chunkfgp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting chunk data with fingerprint %d: %v", chunkfgp, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, chunkTag := range listOfChunkTags {
|
||||
|
|
@ -69,6 +69,21 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
|
|||
matches := make(map[string][]int)
|
||||
|
||||
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)
|
||||
if err != nil && err.Error() == "insufficient timestamps" {
|
||||
continue
|
||||
|
|
@ -76,7 +91,7 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("%s DIFFERENCES: %d\n", songKey, differences)
|
||||
// fmt.Printf("%s DIFFERENCES: %d\n", songKey, differences)
|
||||
if len(differences) >= 2 {
|
||||
matches[songKey] = differences
|
||||
if len(differences) > maxMatchCount {
|
||||
|
|
@ -88,10 +103,10 @@ func Match(sampleAudio []byte) ([]primitive.M, error) {
|
|||
|
||||
sortedChunkTags := sortMatchesByTimeDifference(matches, chunkTags)
|
||||
|
||||
fmt.Println("SORTED CHUNK TAGS: ", sortedChunkTags)
|
||||
fmt.Println("MATCHES: ", matches)
|
||||
// fmt.Println("SORTED CHUNK TAGS: ", sortedChunkTags)
|
||||
// fmt.Println("MATCHES: ", matches)
|
||||
fmt.Println("MATCH: ", maxMatch)
|
||||
fmt.Println()
|
||||
// fmt.Println()
|
||||
return sortedChunkTags, nil
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +138,120 @@ func sortMatchesByTimeDifference(matches map[string][]int, chunkTags map[string]
|
|||
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) {
|
||||
if len(timestamps) < 2 {
|
||||
return nil, fmt.Errorf("insufficient timestamps")
|
||||
|
|
@ -144,17 +273,26 @@ func timeDifference(timestamps []string) ([]int, error) {
|
|||
|
||||
// sort.Ints(timestampsInSeconds)
|
||||
|
||||
differences := []int{}
|
||||
differencesSet := map[int]struct{}{}
|
||||
var differences []int
|
||||
|
||||
for i := len(timestampsInSeconds) - 1; i >= 1; i-- {
|
||||
difference := timestampsInSeconds[i] - timestampsInSeconds[i-1]
|
||||
// maxSeconds = 15
|
||||
if difference > 0 && difference <= 15 {
|
||||
differencesSet[difference] = struct{}{}
|
||||
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.
|
||||
|
|
@ -204,7 +342,10 @@ func FingerprintChunks(chunks [][]complex128, chunkTag *ChunkTag) ([]int64, map[
|
|||
if chunkTag != nil {
|
||||
// bytesPerSecond = (samplingRate * bitDepth * channels) / 8
|
||||
chunksPerSecond = (chunkSize - hopSize) / samplingRate
|
||||
chunksPerSecond = 9
|
||||
chunksPerSecond = len(chunks)
|
||||
|
||||
fmt.Println("CHUNKS PER SECOND: ", chunksPerSecond)
|
||||
chunksPerSecond = 3
|
||||
fmt.Println("CHUNKS PER SECOND: ", chunksPerSecond)
|
||||
// if chunkSize == 4096 {
|
||||
// chunksPerSecond = 10
|
||||
|
|
@ -219,7 +360,7 @@ func FingerprintChunks(chunks [][]complex128, chunkTag *ChunkTag) ([]int64, map[
|
|||
if chunkCount == chunksPerSecond {
|
||||
chunkCount = 0
|
||||
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
18
shazam/utils.go
Normal 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
|
|
@ -13,7 +13,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/interceptor/pkg/intervalpli"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"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) {
|
||||
// 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
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
|
|
@ -90,6 +89,7 @@ func MatchSampleAudio(track *webrtc.TrackRemote) ([]primitive.M, error) {
|
|||
|
||||
// Reset sampleAudio for fresh input
|
||||
// sampleAudio = nil
|
||||
|
||||
// if len(matches) > 0 {
|
||||
// fmt.Println("FOUND A MATCH! - ", matches)
|
||||
// jsonData, err := json.Marshal(matches)
|
||||
|
|
@ -132,7 +132,7 @@ func SetupWebRTC(encodedOffer string) *webrtc.PeerConnection {
|
|||
// Setup the codecs you want to use.
|
||||
// We'll use Opus, but you can also define your own
|
||||
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,
|
||||
}, webrtc.RTPCodecTypeAudio); err != nil {
|
||||
panic(err)
|
||||
|
|
@ -144,16 +144,16 @@ func SetupWebRTC(encodedOffer string) *webrtc.PeerConnection {
|
|||
|
||||
// Register a intervalpli factory
|
||||
// This interceptor sends a PLI every 3 seconds. A PLI causes a keyframe to be generated by the sender.
|
||||
intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
i.Add(intervalPliFactory)
|
||||
// intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// i.Add(intervalPliFactory)
|
||||
|
||||
// Use the default set of Interceptors
|
||||
if err = webrtc.RegisterDefaultInterceptors(m, i); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// // Use the default set of Interceptors
|
||||
// if err = webrtc.RegisterDefaultInterceptors(m, i); err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
|
||||
// Create the API object with the MediaEngine
|
||||
api := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(i))
|
||||
|
|
|
|||
|
|
@ -4,29 +4,27 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"song-recognition/shazam"
|
||||
"song-recognition/utils"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
// "song-recognition/youtube"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/kkdai/youtube/v2"
|
||||
)
|
||||
|
||||
var yellow = color.New(color.FgYellow)
|
||||
|
||||
func DlSingleTrack(url, savePath string) error {
|
||||
func DlSingleTrack(url, savePath string) (int, error) {
|
||||
trackInfo, err := TrackInfo(url)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
fmt.Println("Getting track info...")
|
||||
|
|
@ -34,12 +32,12 @@ func DlSingleTrack(url, savePath string) error {
|
|||
track := []Track{*trackInfo}
|
||||
|
||||
fmt.Println("Now, downloading track...")
|
||||
_, err = dlTrack(track, savePath)
|
||||
totalTracksDownloaded, err := dlTrack(track, savePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return nil
|
||||
return totalTracksDownloaded, nil
|
||||
}
|
||||
|
||||
func DlPlaylist(url, savePath string) (int, error) {
|
||||
|
|
@ -52,7 +50,6 @@ func DlPlaylist(url, savePath string) (int, error) {
|
|||
fmt.Println("Now, downloading playlist...")
|
||||
totalTracksDownloaded, err := dlTrack(tracks, savePath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +80,12 @@ func dlTrack(tracks []Track, path string) (int, error) {
|
|||
numCPUs := runtime.NumCPU()
|
||||
semaphore := make(chan struct{}, numCPUs)
|
||||
|
||||
db, err := utils.NewDbClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
for _, t := range tracks {
|
||||
wg.Add(1)
|
||||
go func(track Track) {
|
||||
|
|
@ -100,30 +103,65 @@ func dlTrack(tracks []Track, path string) (int, error) {
|
|||
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)
|
||||
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
|
||||
}
|
||||
|
||||
trackCopy.Title, trackCopy.Artist = correctFilename(trackCopy.Title, trackCopy.Artist)
|
||||
err = getAudio(ytID, path, trackCopy.Title, trackCopy.Artist)
|
||||
if err != nil {
|
||||
yellow.Printf("Error (2): '%s' by '%s' could not be downloaded: %s\n", trackCopy.Title, trackCopy.Artist, err)
|
||||
// Check if YouTube ID exists
|
||||
ytIdExists, _ := db.SongExists("", "", ytID)
|
||||
if ytIdExists { // try to get the YouTube ID again
|
||||
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
|
||||
}
|
||||
// Process and save audio
|
||||
filename := fmt.Sprintf("%s - %s.m4a", trackCopy.Title, trackCopy.Artist)
|
||||
route := filepath.Join(path, filename)
|
||||
err = processAndSaveSong(route, trackCopy.Title, trackCopy.Artist, ytID)
|
||||
|
||||
ytIdExists, _ := db.SongExists("", "", ytID)
|
||||
if ytIdExists {
|
||||
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 {
|
||||
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)
|
||||
filePath := fmt.Sprintf("%s%s - %s.m4a", path, trackCopy.Title, trackCopy.Artist)
|
||||
|
||||
// Consider removing this and deleting the song file after processing
|
||||
if err := addTags(filePath, *trackCopy); err != nil {
|
||||
yellow.Println("Error adding tags: ", filePath)
|
||||
return
|
||||
|
|
@ -155,7 +193,7 @@ func dlTrack(tracks []Track, path string) (int, error) {
|
|||
}
|
||||
|
||||
/* github.com/kkdai/youtube */
|
||||
func getAudio(id, path, title, artist string) error {
|
||||
func getAudio(id, path, filePath string) error {
|
||||
dir, err := os.Stat(path)
|
||||
if err != nil {
|
||||
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)")
|
||||
}
|
||||
|
||||
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{}
|
||||
video, err := client.GetVideo(id)
|
||||
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) */
|
||||
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
|
||||
using the library github.com/kkdai/youtube,
|
||||
the download fails (and shows the file size as 0 bytes)
|
||||
until the second or third attempt. */
|
||||
var fileSize int64
|
||||
file, err := os.Create(route)
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -214,46 +233,13 @@ func getAudio(id, path, title, artist string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
fileSize, _ = GetFileSize(route)
|
||||
fileSize, _ = GetFileSize(filePath)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
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 {
|
||||
tempFile := file
|
||||
index := strings.Index(file, ".m4a")
|
||||
|
|
@ -292,60 +278,17 @@ func addTags(file string, track Track) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
/* fixes some invalid file names (windows is the capricious one) */
|
||||
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 {
|
||||
func processAndSaveSong(songFilePath, songTitle, songArtist, ytID string) error {
|
||||
db, err := utils.NewDbClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to DB: %d", err)
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Check if the song has been processed and saved before
|
||||
songKey := fmt.Sprintf("%s - %s", songTitle, songArtist)
|
||||
songExists, err := db.SongExists(songKey)
|
||||
audioBytes, err := convertStereoToMono(songFilePath)
|
||||
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{
|
||||
SongTitle: songTitle,
|
||||
|
|
@ -353,24 +296,23 @@ func processAndSaveSong(m4aFile, songTitle, songArtist, ytID string) error {
|
|||
YouTubeID: ytID,
|
||||
}
|
||||
|
||||
// Calculate fingerprints
|
||||
// Fingerprint song
|
||||
chunks := shazam.Chunkify(audioBytes)
|
||||
_, fingerprints := shazam.FingerprintChunks(chunks, &chunkTag)
|
||||
|
||||
// Save fingerprints to MongoDB
|
||||
// Save fingerprints in DB
|
||||
for fgp, ctag := range fingerprints {
|
||||
err := db.InsertChunkTag(fgp, ctag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error inserting document: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Save the song as processed
|
||||
err = db.RegisterSong(songKey)
|
||||
err = db.RegisterSong(songTitle, songArtist, ytID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Fingerprints saved to MongoDB successfully")
|
||||
fmt.Println("Fingerprints saved in MongoDB successfully")
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,6 @@ func TrackInfo(url string) (*Track, error) {
|
|||
endpoint := trackInitialPath + endpointQuery + "&extensions=" + EncodeParam(trackEndPath)
|
||||
|
||||
statusCode, jsonResponse, err := request(endpoint)
|
||||
// fmt.Print("TRACK INFO: ", jsonResponse)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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{
|
||||
Title: gjson.Get(jsonResponse, "data.trackUnion.name").String(),
|
||||
Artist: gjson.Get(jsonResponse, "data.trackUnion.firstArtist.items.0.profile.name").String(),
|
||||
Artists: allArtists,
|
||||
Duration: int(gjson.Get(jsonResponse, "data.trackUnion.duration.totalMilliseconds").Int()),
|
||||
Duration: durationInSeconds,
|
||||
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"]
|
||||
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"]
|
||||
duration := map[bool]string{true: "itemV2.data.trackDuration.totalMilliseconds", false: "track.duration.totalMilliseconds"}[resourceType == "playlist"]
|
||||
|
||||
var tracks []Track
|
||||
items := gjson.Get(jsonResponse, itemList).Array()
|
||||
|
||||
for _, item := range items {
|
||||
durationInSeconds := int(item.Get(duration).Int()) / 1000
|
||||
|
||||
track := &Track{
|
||||
Title: item.Get(songTitle).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"],
|
||||
}
|
||||
|
||||
tracks = append(tracks, *track.buildTrack())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -44,38 +46,51 @@ func DeleteFile(filePath string) {
|
|||
}
|
||||
}
|
||||
|
||||
// Convert M4A file from stereo to mono
|
||||
func ConvertM4aToMono(inputFile, outputFile string) ([]byte, error) {
|
||||
cmd := exec.Command("ffprobe", "-v", "error", "-show_entries", "stream=channels", "-of", "default=noprint_wrappers=1:nokey=1", inputFile)
|
||||
/* fixes some invalid file names (windows is the capricious one) */
|
||||
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 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()
|
||||
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))
|
||||
if channels != "1" {
|
||||
// Convert to mono
|
||||
cmd = exec.Command("ffmpeg", "-i", inputFile, "-af", "pan=mono|c0=c0", outputFile)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("error running ffmpeg: %v", err)
|
||||
|
||||
audioBytes, err := ioutil.ReadFile(stereoFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading stereo file: %v", err)
|
||||
}
|
||||
|
||||
// Resample to 8192 Hz
|
||||
// resampledFile := strings.TrimSuffix(outputFile, filepath.Ext(outputFile)) + "_resampled.m4a"
|
||||
// cmd = exec.Command("ffmpeg", "-i", outputFile, "-ar", "8192", resampledFile)
|
||||
// output, err = cmd.CombinedOutput()
|
||||
// if err := cmd.Run(); err != nil {
|
||||
// return nil, fmt.Errorf("error resampling: %v, %v", err, string(output))
|
||||
// }
|
||||
if channels != "1" {
|
||||
// Convert stereo to mono and downsample by 44100/2
|
||||
cmd = exec.Command("ffmpeg", "-i", stereoFilePath, "-af", "pan=mono|c0=c0", monoFilePath)
|
||||
// cmd = exec.Command("ffmpeg", "-i", stereoFilePath, "-af", "pan=mono|c0=c0", "-ar", "22050", monoFilePath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("error converting stereo to mono: %v", err)
|
||||
}
|
||||
|
||||
audioBytes, err = ioutil.ReadFile(outputFile)
|
||||
audioBytes, err = ioutil.ReadFile(monoFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading input file: %v", err)
|
||||
return nil, fmt.Errorf("error reading mono file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ func convertStringDurationToSeconds(durationStr string) int {
|
|||
|
||||
// GetYoutubeId takes the query as string and returns the search results video ID's
|
||||
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)
|
||||
|
||||
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 searchResults[0].ID, nil
|
||||
return "", fmt.Errorf("could not settle on a song from search result for: %s", searchQuery)
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
// fmt.Println("Search URL: ", ytSearchUrl)
|
||||
|
||||
req, err := http.NewRequest("GET", ytSearchUrl, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("cannot get youtube page")
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ func NewDbClient() (*DbClient, error) {
|
|||
clientOptions := options.Client().ApplyURI(dbUri)
|
||||
client, err := mongo.Connect(context.Background(), clientOptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error connecting to MongoDB: %d", err)
|
||||
}
|
||||
return &DbClient{client: client}, nil
|
||||
}
|
||||
|
|
@ -45,25 +45,50 @@ func (db *DbClient) TotalSongs() (int, error) {
|
|||
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")
|
||||
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
|
||||
if err := existingSongsCollection.FindOne(context.Background(), filter).Decode(&result); err == nil {
|
||||
return true, nil
|
||||
} 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
|
||||
}
|
||||
|
||||
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")
|
||||
_, 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 {
|
||||
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
|
||||
|
|
@ -82,7 +107,7 @@ func (db *DbClient) InsertChunkTag(chunkfgp int64, chunkTag interface{}) error {
|
|||
update := bson.M{"$push": bson.M{"chunkTags": chunkTag}}
|
||||
_, err := chunksCollection.UpdateOne(context.Background(), filter, update)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating chunk data: %v", err)
|
||||
return fmt.Errorf("failed to update chunkTags: %v", err)
|
||||
}
|
||||
return nil
|
||||
} 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
|
||||
_, err = chunksCollection.InsertOne(context.Background(), bson.M{"fingerprint": chunkfgp, "chunkTags": []interface{}{chunkTag}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error inserting chunk data: %v", err)
|
||||
return fmt.Errorf("failed to insert chunk tag: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -109,7 +134,7 @@ func (db *DbClient) GetChunkTags(chunkfgp int64) ([]primitive.M, error) {
|
|||
if err == mongo.ErrNoDocuments {
|
||||
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
|
||||
|
|
@ -137,7 +162,7 @@ func (db *DbClient) GetChunkTagForSong(songTitle, songArtist string) (bson.M, er
|
|||
if err == mongo.ErrNoDocuments {
|
||||
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{}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue