diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..13566b81b --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/flask-hello-world.iml b/.idea/flask-hello-world.iml new file mode 100644 index 000000000..66cd14894 --- /dev/null +++ b/.idea/flask-hello-world.iml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 000000000..105ce2da2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..b742dd436 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..cb00f9bfd --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 42553ee66..42239a80b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,28 @@ -# README +This JavaScript code implements a virtual piano application with functionality for playing notes, recording user input, and managing recordings. Here's an overview of the key components and functionalities: -This is the [Flask](http://flask.pocoo.org/) [quick start](http://flask.pocoo.org/docs/1.0/quickstart/#a-minimal-application) example for [Render](https://render.com). +### Setup and Initialization +- **Frequency Map (`notesFreq`)**: Defines the frequencies for musical notes, facilitating the creation of sound based on piano key presses. +- **DOM Elements Creation**: Dynamically generates piano keys (`div` elements) for each note defined in `notesFreq` and adds them to the page. +- **Audio Context**: Initializes an `AudioContext` for managing and playing sounds. -The app in this repo is deployed at [https://flask.onrender.com](https://flask.onrender.com). +### Sound Generation +- **Oscillator and Gain Node Creation (`createOscillatorAndGainNode`)**: Creates an oscillator for generating waveforms at specific frequencies and a gain node for controlling the volume, including an ADSR envelope for natural sounding note attacks and decays. +- **Start and Stop Note Functions**: Handle starting and stopping notes based on user interactions with piano keys, updating the visual state of keys and managing oscillators to play the corresponding sounds. -## Deployment +### User Interaction +- **Mouse and Pointer Events**: Captures user interactions with piano keys through mouse and pointer events, allowing for playing notes both by clicking and by dragging across keys. +- **Recording Functionality**: Allows users to record their sequences of note presses, including the timing of each note, and provides functionality to stop recording and name the recording. -Follow the guide at https://render.com/docs/deploy-flask. +### Recording Management +- **Playback**: Plays back recorded sequences by scheduling the start and stop times of notes based on the recorded timings. +- **CRUD Operations for Recordings**: Communicates with a backend server to save, list, rename, and delete recordings. This involves sending HTTP requests and handling responses to reflect changes in the UI dynamically. + +### Web Application Interactions +- **Fetching and Displaying Recordings**: Retrieves a list of saved recordings from the server and updates the UI to allow users to **play**, **rename**, or **delete** recordings. +- **Server Communication**: Uses `fetch` API to send and receive data from the server, handling both the creation of new recordings and the retrieval of existing ones. + +### Considerations and Enhancements +- The application emphasizes the use of the Web Audio API for sound generation and control, showcasing how web technologies can create interactive musical experiences. +- It demonstrates handling of complex user interactions, dynamic content creation, and communication with a server-side application for persistent storage. + +This code serves as a practical example of combining various web technologies to build an interactive application. It illustrates key concepts such as DOM manipulation, event handling, asynchronous JavaScript, and working with the Web Audio API. diff --git a/app.py b/app.py index d82c51f0d..c75c5693b 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,115 @@ -from flask import Flask +from flask import Flask, render_template, redirect, jsonify, request +import json +from flask_cors import CORS +from flask_sqlalchemy import SQLAlchemy + app = Flask(__name__) +CORS(app) + +print("start connection to db") +app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://robert:LTrCCDwSsyrNhFKffv5TewRi1TQXX9Hs@dpg-cn3qur5jm4es73bmkga0-a.frankfurt-postgres.render.com/recordingsdatabase' +app.config['SQLALCHEMY_ECHO'] = True +db = SQLAlchemy(app) +print("connected to db") + +class Recording(db.Model): + id = db.Column('id', db.Integer, primary_key=True) + name = db.Column(db.String(50)) + data = db.Column(db.Text) # This stores JSON data as text + + def serialize(self): + return { + "id": self.id, + "name": self.name, + "data": self.data + } + +with app.app_context(): + print("Creating database tables...") + # db.drop_all() delete all tables for dev only + db.create_all() + print("Tables created.") + +# My recordings JS to Flask RESTful API +recordings = {} + @app.route('/') def hello_world(): - return 'Hello, World!' + return render_template("home.html", my_rec=recordings) + + +if __name__ == "__main__": + print("Starting application...") + with app.app_context(): + db.create_all() + app.run(debug=True) + + +# SAVE, DELETE, SHOW + +# Database integration + + + +def jsonify_recordings(recordings): + result = [] + for recording in recordings: + result.append({ + "id": recording.id, + "name": recording.name, + "data": recording.data + }) + return result + + + +@app.route('/saveRecording', methods=["POST"]) +def saveRecording(): + data = request.get_json() # This is your dataOfClicks from the frontend + print(data) + if not data: + return jsonify({"error": "No data provided"}), 400 + + # Assuming you want to use the name as a unique identifier for now, but you could modify this + name = data.get('name', 'Unnamed Recording') + recording_data = json.dumps(data.get('clicks', [])) # Convert the clicks list to a JSON string + + new_recording = Recording(name=name, data=recording_data) + db.session.add(new_recording) + db.session.commit() + + return jsonify({"status": "OK", "id": new_recording.id}) + +@app.route('/list-recordings', methods=['GET']) +def list_recordings(): + recordings = Recording.query.order_by(Recording.id).all() # Fetch all recordings from the database, + sort recordings by ID + return jsonify([recording.serialize() for recording in recordings]) + +@app.route('/rename-recording', methods=['POST']) +def rename_recording(): + data = request.get_json() + if not data or 'id' not in data or 'newName' not in data: + return jsonify({"error": "Invalid request"}), 400 + + recording = Recording.query.get(data['id']) + if recording: + recording.name = data['newName'] + db.session.commit() + return jsonify({"success": True, "id": recording.id, "newName": recording.name}) + else: + return jsonify({"error": "Recording not found"}), 404 + +@app.route('/delete-recording', methods=['POST']) +def delete_recording(): + data = request.get_json() + if not data or 'id' not in data: + return jsonify({"error": "Invalid request"}), 400 + + recording = Recording.query.get(data['id']) + if recording: + db.session.delete(recording) + db.session.commit() + return jsonify({"success": True, "id": data['id']}) + else: + return jsonify({"error": "Recording not found"}), 404 diff --git a/requirements.txt b/requirements.txt index 147ddd086..63e77d32a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ Flask Gunicorn +flask_cors +flask-sqlalchemy +psycopg2 \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 000000000..7b537e81f --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,116 @@ +html, body { + overflow-x: hidden; +} + +body { + width: 100%; + padding-left: 1%; + height: 100%; + margin: 5px; + background-color: #b0c4de; +} + +h1 { + font-family: Georgia, 'Times New Roman', Times, serif; + margin-bottom: 70px; + font-size: 4em; + margin-top: 20px; +} + +#container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; +} + + +.piano-tile { + display: flex; + text-align: center; + align-items: flex-start; + padding-top: 19%; + justify-content: center; + user-select: none; + width: 47px; + /* Adjust the width as needed */ + height: 250px; + /* Adjust the height as needed */ + background-color: white; + border: 3px solid black; + font-size: 25px; + font-weight: bold; + font-family: Ensures; + color: Red; + margin: 15px 1px; + box-sizing: border-box; + /* Ensures that the border width is included in the total width and height */ +} + +.active { + background: linear-gradient(to top, rgb(199, 29, 29), white); +} + +#rand { + font-style: italic; +} + +button { + padding: 7px 14px; + color: Red; + background-color: black; + font-family: Tahoma; + font-size: 16px; +} + +.recording { + font-style: italic; + font-weight: 900; +} + +#rec { + margin-right: 10px; + +} + +li { + white-space: nowrap; +} + +#database { + margin-top: 30px; +} +/* Custom table styling */ +#recordingsTable { + border-collapse: collapse; + width: 100%; + box-shadow: 0 4px 8px rgba(0,0,0,0.4); /* Soft shadow around the table */ + border-radius: 1rem; /* Rounded corners */ +} + +/* Header styling */ +#recordingsTable thead th { + background-color: #007bff; /* Bootstrap primary color */ + color: white; +} + +/* Button styling within the table */ +#recordingsTable button { + border: none; + border-radius: 0.25rem; + padding: 5px 10px; + color: white; + cursor: pointer; /* Hand icon on hover */ +} + +/* Specific button colors */ +.play-btn { background-color: #28a745; } /* Bootstrap success color */ +.rename-btn { background-color: #17a2b8; } /* Bootstrap info color */ +.delete-btn { background-color: #dc3545; } /* Bootstrap danger color */ + +/* Hover effect for buttons */ +#recordingsTable button:hover { + background-color: rgb(0,40,40); + color: red; +} diff --git a/static/js/script.js b/static/js/script.js new file mode 100644 index 000000000..1ba310b49 --- /dev/null +++ b/static/js/script.js @@ -0,0 +1,433 @@ +// Map of note frequencies, associating musical notes with their frequencies +// for sound generation. +const notesFreq = new Map([ + ['C4', 261.625], + ['D4', 293.665], + ['E4', 329.628], + ['F4', 349.228], + ['G4', 391.995], + ['A4', 440], + ['B4', 493.883], + ['C5', 523.251], + ['D5', 587.33], + ['E5', 659.25], + ['F5', 698.46], + ['G5', 783.99], + ['A5', 880.00], + ['B5', 987.77], + ['C6', 1046.50], + ['D6', 1174.66], + ['E6', 1318.51], + ['F6', 1396.91], + ['G6', 1567.98], + ['A6', 1760.00], + ['B6', 1975.53], + ['C7', 2093.00] +]); + + +// Initialize piano keys on the webpage dynamically based on the notesFreq map. +const container = document.querySelector('#container'); +notesFreq.forEach((value, key) => { + const pianoKey = document.createElement('div'); + pianoKey.id = key; + pianoKey.classList.add('piano-tile'); + pianoKey.innerText = key; + container.appendChild(pianoKey); +}); + + +// Select all dynamically created piano keys for interaction. +const pianoTiles = document.querySelectorAll('.piano-tile'); +// Active oscillators map to keep track of currently playing notes. +const activeOscillators = new Map(); +// Function to create an oscillator and gain node for playing a note. +function createOscillatorAndGainNode(pitch) { + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + // Custom waveform for a more natural piano sound. + // Define the harmonic content for the custom waveform + const numberOfHarmonics = 4; + const real = new Float32Array(numberOfHarmonics); + const imag = new Float32Array(numberOfHarmonics); + + // DC offset and fundamental tone + real[0] = 0; imag[0] = 0; // DC offset, not used for audio signal + real[1] = 1; imag[1] = 0; // Fundamental tone + + // Harmonics + real[2] = 0.8; imag[2] = 0; // First harmonic + real[3] = 0.6; imag[3] = 0; // Second harmonic + + // Create the custom periodic waveform based on the defined harmonics + const customWave = audioContext.createPeriodicWave(real, imag); + oscillator.setPeriodicWave(customWave); + // Set pitch. + oscillator.frequency.setValueAtTime(pitch, audioContext.currentTime); + + // ADSR Envelope for realistic note attack and decay. + gainNode.gain.setValueAtTime(0, audioContext.currentTime); // Start with no volume + const attackTime = 0.02; // Attack + gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + attackTime); // Ramp to full volume + + const decayTime = 0.1; // Decay + const sustainLevel = 0.65; // Sustain level + gainNode.gain.linearRampToValueAtTime(sustainLevel, audioContext.currentTime + attackTime + decayTime); // Ramp to sustain level + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + return { oscillator, gainNode }; +} + + +// Start playing a note. +function startNote() { + // Note is the ID of the clicked/touched piano key. + const note = this.id; + // Retrieve frequency from map. + const pitch = notesFreq.get(note); + // Visual feedback for key press. + this.classList.add('active'); + + + // Stop any previously playing oscillator for this note. + if (activeOscillators.has(note)) { + const existing = activeOscillators.get(note); + existing.oscillator.stop(); + existing.oscillator.disconnect(); + existing.gainNode.disconnect(); + } + // Create and start a new oscillator for this note. + const { oscillator, gainNode } = createOscillatorAndGainNode(pitch); + oscillator.start(); + const noteEventId = Date.now(); + activeOscillators.set(note, { oscillator, gainNode, noteEventId }); +} + + +// Stop playing a note. +function stopNote() { + const note = this.id; + this.classList.remove('active'); + const releaseTime = audioContext.currentTime; + // Exit if no oscillator is playing this note. + if (!activeOscillators.has(note)) { + return; + } + // Retrieve and stop the oscillator for this note. + if (activeOscillators.has(note)) { + const { oscillator, gainNode, noteEventId } = activeOscillators.get(note); + // Time for the note to fade out. + const decayDuration = 2; + gainNode.gain.cancelScheduledValues(releaseTime); + gainNode.gain.setValueAtTime(gainNode.gain.value, releaseTime); // New line to set current gain + gainNode.gain.exponentialRampToValueAtTime(0.001, releaseTime + decayDuration); + setTimeout(() => { + // Check if the current note event is still the one that should be stopped + if (activeOscillators.has(note) && activeOscillators.get(note).noteEventId === noteEventId) { + oscillator.stop(); + oscillator.disconnect(); + gainNode.disconnect(); + activeOscillators.delete(note); + } + }, decayDuration * 1000); + } +} + + +// PC touch event handler +let isMouseDown = false; +document.addEventListener('pointerdown', function (event) { + if (event.buttons === 1) { // Check if left mouse button + isMouseDown = true; + } +}, false); +document.addEventListener('pointerup', function () { + isMouseDown = false; +}, false); + +// Setup to handle user interactions with piano keys. +for (const tile of pianoTiles) { + tile.addEventListener('pointerdown', startNote); + tile.addEventListener('pointerup', stopNote); + tile.addEventListener('pointerover', function (event) { + if (isMouseDown) { + startNote.call(this, event); // Play note if mouse is down and pointer moves over a tile + } + }); + tile.addEventListener('pointerleave', stopNote); +} + + +// Initialize the audio context used for playing notes. +const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + +// Variable of global scope initialization. +let dataOfClicks = []; +let startTime; +let dataLi; +let recordedTime; +let recordingName; + + +// Function to capture the time and key when a note is played or released. +function timeNkey(tile) { + const time = Date.now() - startTime; + const key = tile.id; + const json = { + 'time': time, + 'key': key + } + dataOfClicks.push(json); +} + + +// Starts recording user input. +function record() { + recording = true; + recBtn.classList.add('recording'); + recBtn.innerText = 'Recording...'; + dataLi = document.querySelector('#dataCont li'); + startTime = Date.now(); + dataLi.id = startTime; + // Continuously update the display with the recording duration. + showTimeOfRecording(startTime, dataLi); + // Add event listeners to all piano tiles for capturing clicks. + for (const tile of pianoTiles) { + tile.addEventListener('pointerdown', () => timeNkey(tile)); + tile.addEventListener('pointerup', () => timeNkey(tile)); + } + recBtn.addEventListener("click", () => stopRecording(dataLi), { once: true }); +} + +// Function to display a preview of the recorded data in the console. DEV only +function preview() { + console.log(JSON.stringify(dataOfClicks, null, 1)); +} + + + +// Initialization of recording controls. +const recBtn = document.querySelector("#rec"); +recBtn.addEventListener("click", record, { once: true }); + +const jsonBtn = document.querySelector("#jsonPrev"); +/*jsonBtn.addEventListener("click", preview);*/ + +let recording = false; + + + +// Updates the display with the current recording duration. +function showTimeOfRecording(time, dataLi) { + if (recording) { + const newTime = Date.now(); + let realTime = newTime - time; + recordedTime = realTime; + dataLi.innerText = `Recording for ${(realTime / 1000).toFixed(1)} seconds.`; + setTimeout(() => showTimeOfRecording(time, dataLi), 223); + } +} + +// Stops the recording process. +function stopRecording() { + recordingName = prompt("Please enter a name for the recording:", "enter name"); + + if (recordingName !== null && recordingName !== "") { + console.log("Recording name saved:", recordingName); + } else { + recordingName = "My Recording"; + } + + saveData(); + recBtn.addEventListener("click", record, { once: true }); + recording = false; + recBtn.classList.remove('recording'); + recBtn.innerText = 'Record'; + dataLi.innerText = `Recording saved. Duration - ${(recordedTime / 1000).toFixed(2)} seconds.` +} + + +// Saves the recorded data to the server. +function saveData() { + // Package the recording name and data. + const recordingData = { + "name": recordingName, // Set the recording name as desired + "clicks": dataOfClicks // data + }; + console.log(recordingData); + + // Then, proceed with the fetch request, sending recordingData as the body + fetch("https://onkrajreda.onrender.com/saveRecording", { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(recordingData) // Convert the recordingData object into a JSON string + }) + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); // Parse JSON response body + }) + .then(data => { + console.log(data); // Handle success + }) + .catch(error => { + console.error('Error:', error); // Handle errors, such as network issues + }); + fetchRecordings(); + // clear dataOfClicks for next use + dataOfClicks = []; +} + + + +// Fetches the list of recordings from the server and updates the UI to display them. +function fetchRecordings() { + fetch("https://onkrajreda.onrender.com/list-recordings") + .then(response => response.json()) // Parse the JSON response. + .then(data => { + const tbody = document.getElementById('recordingsTable').getElementsByTagName('tbody')[0]; + tbody.innerHTML = ''; // Clear the table body to ensure fresh display of recordings. + // Iterate through each recording received from the server. + data.forEach(recording => { + // Create a new row for each recording with its ID, name, and action buttons. + const row = tbody.insertRow(); + row.insertCell().textContent = recording.id; + row.insertCell().textContent = recording.name; + + // Create and append a 'Play' button for each recording. + const playCell = row.insertCell(); + const playButton = document.createElement('button'); + playButton.textContent = 'Play'; + playButton.onclick = () => playRecording(recording.id, recording.data); + playCell.appendChild(playButton); + + // Create and append a 'Rename' button for each recording. + const renameCell = row.insertCell(); + const renameButton = document.createElement('button'); + renameButton.textContent = 'Rename'; + renameButton.onclick = () => renameRecording(recording.id); + renameCell.appendChild(renameButton); + + // Create and append a 'Delete' button for each recording. + const deleteCell = row.insertCell(); + const deleteButton = document.createElement('button'); + deleteButton.textContent = 'Delete'; + deleteButton.onclick = () => deleteRecording(recording.id); + deleteCell.appendChild(deleteButton); + }); + }); +} + + +// Prompts the user for a new name and sends a request to the server to rename a recording. +function renameRecording(id) { + const newName = prompt('Enter new name:'); + if (newName) { + fetch('https://onkrajreda.onrender.com/rename-recording', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id, newName }), // Send the new name along with the recording ID. + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + fetchRecordings(); // Refresh the table + } else { + alert('Rename failed'); + } + }); + } +} + +// Confirms with the user before sending a request to the server to delete a recording. +function deleteRecording(id) { + if (confirm('Are you sure you want to delete this recording?')) { + fetch('https://onkrajreda.onrender.com/delete-recording', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id }), // Send the recording ID to be deleted. + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + fetchRecordings(); // Refresh the table + } else { + alert('Delete failed'); + } + }); + } +} + + +// Same as startNote but modified for automatic replay +function startNoteAutoplay(key) { + const note = key; + const pitch = notesFreq.get(note); + const tile = document.querySelector(`#${key}`); + tile.classList.add('active'); + + // Stop any existing note for this key + if (activeOscillators.has(note)) { + const existing = activeOscillators.get(note); + existing.oscillator.stop(); + existing.oscillator.disconnect(); + existing.gainNode.disconnect(); + } + + const { oscillator, gainNode } = createOscillatorAndGainNode(pitch); + oscillator.start(); + const noteEventId = Date.now(); + activeOscillators.set(note, { oscillator, gainNode, noteEventId }); +} + +// Same as stopNote but modified for automatic replay +function stopNoteAutoplay(key) { + const note = key; + const tile = document.querySelector(`#${key}`); + tile.classList.remove('active'); + const releaseTime = audioContext.currentTime; + const { oscillator, gainNode, noteEventId } = activeOscillators.get(note); + const decayDuration = 2; + gainNode.gain.cancelScheduledValues(releaseTime); + gainNode.gain.setValueAtTime(gainNode.gain.value, releaseTime); // New line to set current gain + gainNode.gain.exponentialRampToValueAtTime(0.001, releaseTime + decayDuration); + setTimeout(() => { + // Check if the current note event is still the one that should be stopped + if (activeOscillators.has(note) && activeOscillators.get(note).noteEventId === noteEventId) { + oscillator.stop(); + oscillator.disconnect(); + gainNode.disconnect(); + activeOscillators.delete(note); + } + }, decayDuration * 1000); +} + + +// Plays the playback of the recording with note timings and keys +function playRecording(id, jsonData) { + console.log('Playing recording:', id, 'data: ', jsonData); + const data = JSON.parse(jsonData); // parse json + data.forEach(({time, key}) => { // iterate over each press + console.log(`key - ${key}, time - ${time}ms`); + setTimeout(function () { + if(!activeOscillators.has(key)) { + startNoteAutoplay(key); // Start the note if not already playing + } else { + stopNoteAutoplay(key); // Stop the note if it is playing + } + }, time); // activate start or stop given the recorded time of key press + }); +} + +// Start the website by populating the table with server data +fetchRecordings(); \ No newline at end of file diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 000000000..5099a3e96 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,60 @@ + + + + + + + Klaviatura + + + + + + +

Piano Virtuosso~

+
+
+
+
+ +
+ +
    +
  • +
+
+
+
+
+
+ +
+ +
+
+
+ Database connection + + + + + + + + + + + + + +
IDNamePlayRenameDelete
+
+
+
+ + + + \ No newline at end of file