@ -0,0 +1,133 @@ | |||
# Logs | |||
logs | |||
*.log | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
lerna-debug.log* | |||
.pnpm-debug.log* | |||
# Diagnostic reports (https://nodejs.org/api/report.html) | |||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json | |||
# Runtime data | |||
pids | |||
*.pid | |||
*.seed | |||
*.pid.lock | |||
# Directory for instrumented libs generated by jscoverage/JSCover | |||
lib-cov | |||
# Coverage directory used by tools like istanbul | |||
coverage | |||
*.lcov | |||
# nyc test coverage | |||
.nyc_output | |||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) | |||
.grunt | |||
# Bower dependency directory (https://bower.io/) | |||
bower_components | |||
# node-waf configuration | |||
.lock-wscript | |||
# Compiled binary addons (https://nodejs.org/api/addons.html) | |||
build/Release | |||
# Dependency directories | |||
node_modules/ | |||
jspm_packages/ | |||
# Snowpack dependency directory (https://snowpack.dev/) | |||
web_modules/ | |||
# TypeScript cache | |||
*.tsbuildinfo | |||
# Optional npm cache directory | |||
.npm | |||
# Optional eslint cache | |||
.eslintcache | |||
# Optional stylelint cache | |||
.stylelintcache | |||
# Microbundle cache | |||
.rpt2_cache/ | |||
.rts2_cache_cjs/ | |||
.rts2_cache_es/ | |||
.rts2_cache_umd/ | |||
# Optional REPL history | |||
.node_repl_history | |||
# Output of 'npm pack' | |||
*.tgz | |||
# Yarn Integrity file | |||
.yarn-integrity | |||
# dotenv environment variable files | |||
.env | |||
.env.development.local | |||
.env.test.local | |||
.env.production.local | |||
.env.local | |||
# parcel-bundler cache (https://parceljs.org/) | |||
.cache | |||
.parcel-cache | |||
# Next.js build output | |||
.next | |||
out | |||
# Nuxt.js build / generate output | |||
.nuxt | |||
dist | |||
# Gatsby files | |||
.cache/ | |||
# Comment in the public line in if your project uses Gatsby and not Next.js | |||
# https://nextjs.org/blog/next-9-1#public-directory-support | |||
# public | |||
# vuepress build output | |||
.vuepress/dist | |||
# vuepress v2.x temp and cache directory | |||
.temp | |||
.cache | |||
# Docusaurus cache and generated files | |||
.docusaurus | |||
# Serverless directories | |||
.serverless/ | |||
# FuseBox cache | |||
.fusebox/ | |||
# DynamoDB Local files | |||
.dynamodb/ | |||
# TernJS port file | |||
.tern-port | |||
# Stores VSCode versions used for testing VSCode extensions | |||
.vscode-test | |||
# yarn v2 | |||
.yarn/cache | |||
.yarn/unplugged | |||
.yarn/build-state.yml | |||
.yarn/install-state.gz | |||
.pnp.* | |||
uploads |
@ -0,0 +1,162 @@ | |||
const express = require('express'); | |||
const http = require('http'); | |||
const socketIo = require('socket.io'); | |||
const multer = require('multer'); | |||
const fs = require('fs'); | |||
const path = require('path'); | |||
const { OBSWebSocket } = require('obs-websocket-js'); | |||
const app = express(); | |||
const server = http.createServer(app); | |||
const io = socketIo(server); | |||
const obs = new OBSWebSocket(); | |||
const OBS_HOST = 'localhost'; // If OBS is running on the same machine | |||
const OBS_PORT = 4455; // Default WebSocket port | |||
const OBS_PASSWORD = 'jBHD5lLvKtHIjIwC'; // Add your WebSocket password here if set | |||
// Set up file storage for uploaded speeches | |||
const upload = multer({ dest: 'uploads/' }); | |||
let speechText = []; | |||
let currentPosition = 0; | |||
// File to store speech lines | |||
const SPEECH_LINES_FILE = 'speechLines.txt'; | |||
const SRT_FILE_NAME = 'captions.srt'; | |||
// Serve static files (HTML views) | |||
app.use(express.static(path.join(__dirname, 'public'))); | |||
// Connect to OBS WebSocket | |||
async function connectToOBS() { | |||
try { | |||
await obs.connect(`ws://${OBS_HOST}:${OBS_PORT}`, OBS_PASSWORD); | |||
console.log('Connected to OBS WebSocket'); | |||
} catch (error) { | |||
console.error('Failed to connect to OBS WebSocket:', error); | |||
} | |||
} | |||
connectToOBS(); | |||
// Load speech lines from file | |||
function loadSpeechLines() { | |||
if (fs.existsSync(SPEECH_LINES_FILE)) { | |||
const fileContents = fs.readFileSync(SPEECH_LINES_FILE, 'utf-8'); | |||
speechText = fileContents.split('\n').map(line => line.trim()).filter(line => line.length > 0); | |||
currentPosition = 0; // Reset the position to the start | |||
console.log('Loaded speech lines from file.'); | |||
} | |||
} | |||
// Load speech lines when the server starts | |||
loadSpeechLines(); | |||
// Serve speaker view | |||
app.get('/speaker', (req, res) => { | |||
res.sendFile(path.join(__dirname, 'public', 'speaker.html')); | |||
}); | |||
// Serve audience/projector view | |||
app.get('/audience', (req, res) => { | |||
res.sendFile(path.join(__dirname, 'public', 'audience.html')); | |||
}); | |||
// Endpoint to handle file upload | |||
app.post('/upload', upload.single('speech'), (req, res) => { | |||
const filePath = path.join(process.cwd(), req.file.path); | |||
const fileContents = fs.readFileSync(filePath, 'utf-8'); | |||
speechText = fileContents.split('\n').map(line => line.trim()).filter(line => line.length > 0); | |||
currentPosition = 0; | |||
// Save the loaded speech lines to a file | |||
fs.writeFileSync(SPEECH_LINES_FILE, speechText.join('\n')); | |||
io.emit('load_speech', speechText); | |||
res.send('File uploaded and speech loaded.'); | |||
}); | |||
// Handle WebSocket connections | |||
io.on('connection', (socket) => { | |||
console.log('Client connected'); | |||
// Send the full speech to any newly connected client | |||
if (speechText.length > 0) { | |||
socket.emit('load_speech', speechText); | |||
} | |||
// Handle position updates from the speaker view | |||
socket.on('update_position', (newPosition) => { | |||
currentPosition = newPosition; | |||
io.emit('update_caption', currentPosition); // Send to all clients | |||
const currentLine = speechText[currentPosition]; | |||
updateOBSSubtitle(currentLine); | |||
generateSRT(currentLine); | |||
}); | |||
}); | |||
async function updateOBSSubtitle(captionText) { | |||
try { | |||
await obs.call('SendStreamCaption', { | |||
captionText: captionText | |||
}); | |||
console.log('Updated OBS Text Source:', captionText); | |||
} catch (error) { | |||
console.error('Error updating OBS Text Source:', error); | |||
}0 | |||
} | |||
async function generateSRT(captionText) { | |||
try { | |||
// Check if recording is enabled | |||
const recordStatus = await obs.call('GetRecordStatus'); | |||
if (recordStatus && recordStatus.outputActive) { | |||
// Get the recording timecode | |||
const seconds = recordStatus.outputDuration / 1000; // Assuming this returns a time in seconds | |||
// Format timecode for SRT (HH:MM:SS,ms) | |||
const startTime = formatTimecode(seconds); | |||
const endTime = formatTimecode(seconds + 5); // Assume each line is displayed for 5 seconds | |||
// Create SRT entry | |||
const srtEntry = `${currentPosition + 1}\n${startTime} --> ${endTime}\n${captionText}\n\n`; | |||
fs.appendFileSync(SRT_FILE_NAME, srtEntry); // Append to SRT file | |||
console.log('Updated SRT file:', SRT_FILE_NAME); | |||
} | |||
} catch (error) { | |||
console.error('Error generating SRT:', error); | |||
} | |||
} | |||
function formatTimecode(seconds) { | |||
const date = new Date(0); | |||
date.setSeconds(seconds); | |||
const hours = String(date.getUTCHours()).padStart(2, '0'); | |||
const minutes = String(date.getUTCMinutes()).padStart(2, '0'); | |||
const secs = String(date.getUTCSeconds()).padStart(2, '0'); | |||
const millis = String((seconds % 1) * 1000).padStart(3, '0'); | |||
return `${hours}:${minutes}:${secs},${millis}`; | |||
} | |||
const PORT = process.env.PORT || 3000; | |||
server.listen(PORT, () => { | |||
console.log(`Server running on port ${PORT}`); | |||
}); | |||
const ngrok = require("@ngrok/ngrok"); | |||
async function startTunnel() { | |||
const listener = await ngrok.forward({ | |||
addr: PORT, | |||
authtoken: "2nCZjA9FOMFVdHAhIDyI0Tn8y0A_5oFkSL7kXve8RcCeJMXKU", | |||
domain: "explicitly-trusty-javelin.ngrok-free.app", | |||
proto: "http" | |||
}); | |||
console.log(`Ingress established at: ${listener.url()}`); | |||
} | |||
startTunnel(); |
@ -0,0 +1,24 @@ | |||
{ | |||
"name": "speechsub", | |||
"version": "1.0.0", | |||
"main": "index.js", | |||
"scripts": { | |||
"test": "echo \"Error: no test specified\" && exit 1" | |||
}, | |||
"bin": "app.js", | |||
"keywords": [], | |||
"author": "", | |||
"license": "ISC", | |||
"description": "", | |||
"dependencies": { | |||
"@ngrok/ngrok": "^1.4.1", | |||
"express": "^4.21.0", | |||
"multer": "^1.4.5-lts.1", | |||
"obs-websocket-js": "^5.0.6", | |||
"socket.io": "^4.8.0" | |||
}, | |||
"pkg": { | |||
"assets": "public/*", | |||
"outputPath": "dist" | |||
} | |||
} |
@ -0,0 +1,106 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="UTF-8"> | |||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
<title>Projector View</title> | |||
<style> | |||
body { | |||
font-family: Futura, sans-serif; | |||
font-size: 32px; | |||
background-color: green; | |||
color: white; | |||
text-align: center; /* Align text to the left */ | |||
margin: 0; | |||
padding: 0; | |||
-webkit-text-fill-color: white; | |||
-webkit-text-stroke-color: black; | |||
-webkit-text-stroke-width: 6.00px; | |||
paint-order: stroke fill; | |||
} | |||
#container { | |||
overflow: hidden; | |||
height: 200px; /* Ensure container is tall enough for 4 lines */ | |||
max-width: 42ch; | |||
position: relative; | |||
padding-left: 20px; /* Add some padding to the left */ | |||
margin-left: auto; | |||
margin-right: auto; | |||
} | |||
#content { | |||
position: absolute; | |||
top: 0; | |||
transition: top 0.5s ease; /* Smooth scrolling */ | |||
} | |||
.line { | |||
opacity: 1; | |||
white-space: normal; /* Allow lines to wrap */ | |||
word-wrap: break-word; /* Break long words if needed */ | |||
margin: 5px 0; | |||
} | |||
.currentLine { | |||
font-weight: bold; | |||
opacity: 1; | |||
} | |||
/* Ensure there's space for 4 lines to be displayed */ | |||
#container { | |||
max-height: 400px; | |||
} | |||
</style> | |||
</head> | |||
<body> | |||
<div id="container"> | |||
<div id="content"></div> | |||
</div> | |||
<script src="/socket.io/socket.io.js"></script> | |||
<script> | |||
const socket = io(); | |||
const contentElement = document.getElementById('content'); | |||
let speechLines = []; | |||
let currentLineIndex = 0; | |||
// Display 4 lines (previous, current, next, and an extra one above the current line) | |||
function updateDisplay() { | |||
contentElement.innerHTML = ''; // Clear previous lines | |||
const start = Math.max(0, currentLineIndex - 2); // Show 2 lines before current | |||
const end = Math.min(speechLines.length, currentLineIndex + 2); // Show 2 lines after current | |||
// Create a new block of lines | |||
const displayLines = speechLines.slice(start, end).map((line, index) => { | |||
const div = document.createElement('div'); | |||
div.classList.add('line'); | |||
if (index === 2) div.classList.add('currentLine'); // Highlight the current line | |||
div.textContent = line; | |||
return div; | |||
}); | |||
displayLines.forEach(div => contentElement.appendChild(div)); | |||
// Scroll animation based on the height of each line | |||
const lineHeight = document.querySelector('.line').offsetHeight; | |||
const previousLinesHeight = (currentLineIndex - start - 1) * lineHeight; // Offset for lines above the current line | |||
contentElement.style.top = `-${previousLinesHeight}px`; | |||
} | |||
// Receive updates from the server | |||
socket.on('update_caption', (newPosition) => { | |||
currentLineIndex = newPosition; | |||
updateDisplay(); | |||
}); | |||
// Load the speech when a new client connects | |||
socket.on('load_speech', (lines) => { | |||
speechLines = lines; | |||
currentLineIndex = 0; | |||
updateDisplay(); // Display the initial lines | |||
}); | |||
</script> | |||
</body> | |||
</html> |
@ -0,0 +1,15 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="UTF-8"> | |||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
<title>Upload Speech</title> | |||
</head> | |||
<body> | |||
<h1>Upload Speech File</h1> | |||
<form action="/upload" method="POST" enctype="multipart/form-data"> | |||
<input type="file" name="speech" accept=".txt"> | |||
<button type="submit">Upload</button> | |||
</form> | |||
</body> | |||
</html> |
@ -0,0 +1,74 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="UTF-8"> | |||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
<title>Speaker View</title> | |||
<style> | |||
body { | |||
font-family: Arial, sans-serif; | |||
padding: 20px; | |||
background-color: white; | |||
} | |||
.line { | |||
font-size: 20px; | |||
margin: 5px 0; | |||
} | |||
.currentLine { | |||
background-color: yellow; | |||
} | |||
</style> | |||
</head> | |||
<body> | |||
<h1>Speaker View</h1> | |||
<div id="speech"></div> | |||
<script src="/socket.io/socket.io.js"></script> | |||
<script> | |||
const socket = io(); | |||
const speechDiv = document.getElementById('speech'); | |||
let speechLines = []; | |||
let currentLineIndex = 0; | |||
// Render the speech and highlight the current line | |||
function renderSpeech() { | |||
speechDiv.innerHTML = ''; | |||
speechLines.forEach((line, index) => { | |||
const div = document.createElement('div'); | |||
div.classList.add('line'); | |||
if (index === currentLineIndex) { | |||
div.classList.add('currentLine'); | |||
} | |||
div.textContent = line; | |||
div.onclick = () => updatePosition(index); // Allow clicking to select a line | |||
speechDiv.appendChild(div); | |||
}); | |||
} | |||
// Send updated position to the server | |||
function updatePosition(newPosition) { | |||
currentLineIndex = newPosition; | |||
socket.emit('update_position', currentLineIndex); | |||
renderSpeech(); | |||
} | |||
// Listen for speech text and load it | |||
socket.on('load_speech', (lines) => { | |||
speechLines = lines; | |||
currentLineIndex = 0; | |||
renderSpeech(); | |||
}); | |||
// Add keyboard navigation | |||
document.addEventListener('keydown', (event) => { | |||
if (event.key === 'ArrowDown' && currentLineIndex < speechLines.length - 1) { | |||
updatePosition(currentLineIndex + 1); | |||
} else if (event.key === 'ArrowUp' && currentLineIndex > 0) { | |||
updatePosition(currentLineIndex - 1); | |||
} | |||
}); | |||
</script> | |||
</body> | |||
</html> |