@ -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> |