// OpenAI Whispers API Voice Controlled Game - tomesck2 + gowrann2 CA318 2023
// By Kevin James Tomescu - 21435066 - tomesck2 + Niamh Gowran - 21389501 - gowrann2
// Initializing variables
var apikey = "";
var mediaRecorder;
var audioChunks = [];
var recordingLength = 1250; // Length of each recording snippet in milliseconds
var isProcessingAudio = false; // flag for audio processing
var canReceiveCommand = true; // flag for receiving commands
var gravity = 0.025; // Gravity effect
var velocity = 0; // vertical velocity
var velocityX = 0; // Horizontal velocity
var moveSpeed = 5; // Speed for left/right movements
// variables for side scrolling background
var backgroundX = 0;
var backgroundSpeed = -0.02; // negative to have it move left
var score = 0; // Initialize score
var lives = 3; // Start with 3 lives
var collisionCooldown = false; // Cooldown for collision detection
var myObstacle;
var pipeFrequency = 1500; // Milliseconds
var lastPipeTime = Date.now();
var myObstacles = [];
//HTML code for the page, including the API key input and the buttons to start recording.
//The result of the API call is displayed in the div with id="apiResponse".
document.write(`
<div id="gameContainer" style="text-align: center; margin: auto; width: 50%;">
<div id="enterkey">
<h3>Enter API key</h3>
Enter API key: <input style='width:25vw;' maxlength='2000' id="apikey" value=''>
<button onclick='setkey();' class='ab-normbutton'>Set API key</button>
</div>
<canvas id="gameCanvas" width="700" height="500"></canvas>
<div id="gameInfo">
<p>Say your command when the timer hits 0!</p>
<p>Commands are Go UP, Go DOWN, Go LEFT, Go RIGHT - speak to control your character.</p>
<div id="timerDisplay"></div>
<div id="whispersResult">
<h3>Whispers API Response:</h3>
<div id="apiResponse"></div>
</div>
<div id="scoreDisplay">
<h3>Score: <span id="score">0</span></h3>
</div>
<div id="livesDisplay">
<h3>Lives: <span id="lives">3</span></h3>
</div>
<button onclick="resetGame()">Restart Game</button>
</div>
`);
// CSS for the game container
$("#gameContainer").css({
textAlign: "center",
maxWidth: "1000px",
margin: "auto",
});
// CSS for the canvas
$("#gameCanvas").css({
border: "3px solid black",
"background-color": "transparent",
margin: "20px 0", // Add margin to separate from other elements
});
var backgroundImage = new Image();
backgroundImage.crossOrigin = "Anonymous";
backgroundImage.onload = function () {
drawBackground();
drawSquare();
};
backgroundImage.src =
"https://media.istockphoto.com/id/1171564349/video/retro-land.jpg?s=640x640&k=20&c=UytoK5VuR3nXezFTIryRH6D4mHUE846zUBZhlIJA5cw=";
function drawBackground() {
// Draw the moving background
ctx.drawImage(backgroundImage, backgroundX, 0, canvas.width, canvas.height);
// Draw a second image to the right of the first to loop the background
ctx.drawImage(
backgroundImage,
backgroundX + canvas.width,
0,
canvas.width,
canvas.height
);
}
function updateScoreDisplay() {
document.getElementById("score").innerHTML = score;
}
function updateLivesDisplay() {
document.getElementById("lives").innerHTML = lives;
}
function startGame() {
myObstacle = new component(10, 300, "green", 300, 120);
gameLoop(); // Start the game loop
}
function gameLoop() {
var currentTime = Date.now();
//myObstacle.update();
// Update the state of the game
updateGameState();
// Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Update and draw the background
backgroundX += backgroundSpeed;
if (backgroundX <= -canvas.width) {
backgroundX = 0;
}
drawBackground();
// Draw the square
drawSquare();
if (currentTime - lastPipeTime > pipeFrequency) {
var extraDistance = Math.random() * (400 - 200) + 100; // Random additional distance off-screen between 100 and 300
var obstacleStartX = canvas.width + extraDistance;
var maxHeight = 200;
var minHeight = 50;
var heightTop =
Math.floor(Math.random() * (maxHeight - minHeight + 1)) + minHeight;
var heightBottom =
Math.floor(Math.random() * (maxHeight - minHeight + 1)) + minHeight;
myObstacles.push(new component(30, heightTop, "green", obstacleStartX, 0));
myObstacles.push(
new component(
30,
heightBottom,
"green",
obstacleStartX,
canvas.height - heightBottom
)
);
lastPipeTime = currentTime;
// Adjust pipeFrequency for staggered distances
pipeFrequency = Math.random() * (2500 - 1500) + 1500; // Random frequency between 1.5 to 2.5 seconds
}
// Update and draw each obstacle
for (var i = 0; i < myObstacles.length; i++) {
myObstacles[i].x -= 0.5; // Move obstacle
myObstacles[i].update(); // Draw obstacle
}
myObstacles.forEach((obstacle) => {
if (!obstacle.passed && square.x > obstacle.x + obstacle.width) {
score++; // Increment score
obstacle.passed = true; // Mark this pipe as passed
console.log("Pipe passed");
updateScoreDisplay();
}
});
if (checkCollision(square, myObstacles)) {
alert("You hit a pipe!");
if (lives <= 0) {
gameOver();
return;
}
}
// Queue the next loop
requestAnimationFrame(gameLoop);
}
function gameOver() {
ctx.font = "48px sans-serif";
ctx.fillStyle = "red";
ctx.textAlign = "center";
ctx.fillText("Game Over!", canvas.width / 2, canvas.height / 2);
}
function resetGame() {
// Reset game variables
score = 0;
lives = 3;
backgroundX = 0;
velocity = 0;
velocityX = 0;
myObstacles = []; // Reset obstacles array
lastPipeTime = Date.now(); // Reset pipe timer
// Update displays
updateScoreDisplay();
updateLivesDisplay();
// Restart the game loop
requestAnimationFrame(gameLoop);
}
function updateGameState() {
// Update background position for side-scrolling
backgroundX += backgroundSpeed;
if (backgroundX <= -canvas.width) {
backgroundX = 0;
}
// Apply gravity and vertical movement
velocity += gravity;
square.y += velocity;
// Apply horizontal movement
square.x += velocityX;
// Implement friction or stopping condition so the square stops moving when commands are not given
velocityX *= 0.9; // This will slow down the square's movement over time
// Add boundaries to stop the square from moving outside the canvas
if (square.x < 0) {
square.x = 0;
velocityX = 0;
} else if (square.x + square.size > canvas.width) {
square.x = canvas.width - square.size;
velocityX = 0;
}
if (square.y + square.size > canvas.height) {
square.y = canvas.height - square.size;
velocity = 0;
} else if (square.y < 0) {
square.y = 0;
velocity = 0;
}
}
// Set the API key and display a message
// credit to https://ancientbrain.com/viewjs.php?world=2850716357 for the starter code for this function
function setkey() {
apikey = $("#apikey").val().trim();
$("#enterkey").html("<b>API key has been set. Setting up...</b>");
startGame();
startContinuousRecording().then(() => {
$("#enterkey").html("<b>Ready to receive commands!</b>");
});
}
function updateTimerDisplay(timeLeft) {
$("#timerDisplay").text(timeLeft + "s");
}
// Start recording audio from the microphone
// credit to https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder for mediaRecorder documentation and example
async function startContinuousRecording() {
return new Promise(async (resolve, reject) => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (event) => {
if (!isProcessingAudio) {
audioChunks.push(event.data);
sendAudioToWhispersAPI();
}
};
setInterval(() => {
if (mediaRecorder.state === "recording") {
mediaRecorder.stop();
} else {
audioChunks = [];
isProcessingAudio = false; // Reset the audio processing flag
mediaRecorder.start(); // start recording again
}
}, recordingLength);
resolve();
} catch (error) {
reject(error);
}
});
}
// Send audio to Flask proxy server and display the result
// credit to https://platform.openai.com/docs/guides/speech-to-text for the API documentation
// credit to https://developer.mozilla.org/en-US/docs/Web/API/FileReader for the FileReader documentation
// credit to https://api.jquery.com/jquery.ajax/ for the jQuery AJAX documentation
// credit to https://developer.mozilla.org/en-US/docs/Glossary/Base64 for the base64 documentation
function sendAudioToWhispersAPI() {
isProcessingAudio = true; // set the flag to true
// make sure there are audio chunks available
if (audioChunks.length === 0) {
console.error("No audio data available");
return;
}
// Properly construct the Blob from audio chunks
const audioBlob = new Blob(audioChunks, { type: "audio/wav" });
// Check if the created object is indeed a Blob
if (!(audioBlob instanceof Blob)) {
console.error("Failed to create a Blob:", audioBlob);
return;
}
const reader = new FileReader();
reader.onloadend = function () {
const base64data = reader.result;
// Prepare the request payload, including the model
const payload = {
api_endpoint: "https://api.openai.com/v1/audio/transcriptions", // OpenAI Transcriptions API endpoint
api_key: apikey, // API key
model: "whisper-1", // Model name
data: base64data, // Audio data in base64 format, as required by the API
};
// send request and handle response
$.ajax({
type: "POST",
url: "https://gowtom-proxy.azurewebsites.net/speech-to-text", // URL of our Flask server
data: JSON.stringify(payload),
contentType: "application/json",
success: function (response) {
$("#apiResponse").text(JSON.stringify(response, null, 2));
if (response && response.text) {
processApiResponse(response.text);
isProcessingAudio = false;
} else {
isProcessingAudio = false;
console.error("Unexpected response format:", response);
}
},
error: function (_, __, error) {
$("#apiResponse").html(
"<font color='red'><b>An error occurred: " + error + "</b></font>"
);
},
});
};
reader.readAsDataURL(audioBlob); // Convert blob to base64
}
// Game Logic
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
let square = { x: canvas.width / 2 - 25, y: canvas.height - 50, size: 50 };
function drawSquare() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground();
ctx.fillStyle = "red";
ctx.fillRect(square.x, square.y, square.size, square.size);
}
// Process the response from Whispers API to control the game
async function processApiResponse(responseText) {
if (!canReceiveCommand) return;
canReceiveCommand = false;
await moveSquare(
responseText
.replace(/[^\w\s]/g, "")
.toLowerCase()
.trim()
);
var timeLeft = recordingLength / 1000;
updateTimerDisplay(timeLeft);
var timer = setInterval(() => {
timeLeft--;
updateTimerDisplay(timeLeft);
if (timeLeft <= 0) {
clearInterval(timer);
canReceiveCommand = true;
updateTimerDisplay(0);
}
}, 1000);
}
function component(width, height, color, x, y) {
this.width = width;
this.height = height;
this.x = x;
this.y = y;
this.passed = false;
this.update = function () {
ctx.fillStyle = color;
ctx.fillRect(this.x, this.y, this.width, this.height);
};
}
function checkCollision(square, obstacles) {
if (collisionCooldown) return false;
for (var i = 0; i < obstacles.length; i++) {
var obstacle = obstacles[i];
var myleft = square.x;
var myright = square.x + square.size;
var mytop = square.y;
var mybottom = square.y + square.size;
var otherleft = obstacle.x;
var otherright = obstacle.x + obstacle.width;
var othertop = obstacle.y;
var otherbottom = obstacle.y + obstacle.height;
if (
mybottom < othertop ||
mytop > otherbottom ||
myright < otherleft ||
myleft > otherright
) {
continue;
}
lives -= 1;
updateLivesDisplay();
myObstacles.forEach((obstacle) => (obstacle.passed = false));
collisionCooldown = true;
setTimeout(() => {
collisionCooldown = false;
}, 1000); // Cooldown for 1 second (adjust as needed)
return true; // collision detected
}
return false; // no collision
}
async function moveSquare(direction) {
const commands = direction.toLowerCase().split(" "); // Split the direction into individual commands
commands.forEach((command) => {
if (
![
"up",
"op",
"oop",
"down",
"left",
"right",
"write",
"ride",
"uh",
"oh",
].includes(command)
) {
// If the command is not one of the valid directions, log an error message and return
console.log("Could not move the square, the command was incorrect");
return;
}
// Handle the command and move the character
switch (command) {
case "up":
case "op":
case "oop":
case "uh":
case "oh":
// Negative velocity to move up
velocity = -4;
break;
case "down":
velocity = 2.5;
break;
case "left":
velocityX = -moveSpeed;
break;
case "right":
case "write":
case "ride":
// Move the square right by adding the moveAmount to its x-coordinate
velocityX = moveSpeed;
break;
}
});
drawSquare(); // Redraw the square on the canvas after moving
}
// Credits / references
// https://ancientbrain.com/viewjs.php?world=2850716357
// https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder
// https://platform.openai.com/docs/guides/speech-to-text
// https://developer.mozilla.org/en-US/docs/Web/API/FileReader
// https://api.jquery.com/jquery.ajax/
// https://developer.mozilla.org/en-US/docs/Glossary/Base64
// https://openai.com/research/whisper
// https://github.com/openai/whisper
// https://www.youtube.com/watch?v=jj5ADM2uywg Flappy Bird JS Tutorial
// https://www.w3schools.com/graphics/game_intro.asp - W3Schools HTML JS Flappy Bird Guide