Spaces:
Paused
Paused
Upload 2 files
Browse files- server.py +20 -16
- templates/index.html +107 -16
server.py
CHANGED
|
@@ -37,7 +37,7 @@ def index():
|
|
| 37 |
|
| 38 |
@app.route("/get-token", methods=["GET"])
|
| 39 |
def get_token():
|
| 40 |
-
"""Generates a Livekit token
|
| 41 |
identity = f"user-{uuid.uuid4()}"
|
| 42 |
|
| 43 |
# Create a token with permissions to join a specific room
|
|
@@ -45,32 +45,29 @@ def get_token():
|
|
| 45 |
api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
| 46 |
.with_identity(identity)
|
| 47 |
.with_name(f"Visitor-{identity}")
|
| 48 |
-
# CORRECTED: api.VideoGrant changed to api.VideoGrants
|
| 49 |
.with_grants(api.VideoGrants(room_join=True, room=ROOM_NAME))
|
| 50 |
)
|
| 51 |
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
def run_agent_worker():
|
| 56 |
"""
|
| 57 |
Runs the app.py agent worker in a subprocess.
|
| 58 |
-
This worker needs its own token to connect to the room.
|
| 59 |
"""
|
| 60 |
print("Starting Livekit Agent worker...")
|
| 61 |
|
| 62 |
-
# The agent also needs a token to join the room
|
| 63 |
agent_token = (
|
| 64 |
api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
| 65 |
.with_identity("devraze-agent")
|
| 66 |
.with_name("DevRaze")
|
| 67 |
-
# CORRECTED: api.VideoGrant changed to api.VideoGrants
|
| 68 |
.with_grants(api.VideoGrants(room_join=True, room=ROOM_NAME, room_admin=True, hidden=True))
|
| 69 |
)
|
| 70 |
|
| 71 |
-
# The `livekit-agent` CLI is the standard way to run agent workers
|
| 72 |
-
# It takes the URL and token as arguments.
|
| 73 |
-
# We pass the module and class name of our agent.
|
| 74 |
command = [
|
| 75 |
"livekit-agent",
|
| 76 |
"run-agent",
|
|
@@ -82,14 +79,21 @@ def run_agent_worker():
|
|
| 82 |
]
|
| 83 |
|
| 84 |
try:
|
| 85 |
-
# Using
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
except FileNotFoundError:
|
| 91 |
print("Error: 'livekit-agent' command not found.")
|
| 92 |
-
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
if __name__ == "__main__":
|
|
@@ -98,5 +102,5 @@ if __name__ == "__main__":
|
|
| 98 |
agent_thread.daemon = True
|
| 99 |
agent_thread.start()
|
| 100 |
|
| 101 |
-
# Run the Flask app
|
| 102 |
app.run(host="0.0.0.0", port=7860)
|
|
|
|
| 37 |
|
| 38 |
@app.route("/get-token", methods=["GET"])
|
| 39 |
def get_token():
|
| 40 |
+
"""Generates a Livekit token and returns it with the correct WS URL."""
|
| 41 |
identity = f"user-{uuid.uuid4()}"
|
| 42 |
|
| 43 |
# Create a token with permissions to join a specific room
|
|
|
|
| 45 |
api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
| 46 |
.with_identity(identity)
|
| 47 |
.with_name(f"Visitor-{identity}")
|
|
|
|
| 48 |
.with_grants(api.VideoGrants(room_join=True, room=ROOM_NAME))
|
| 49 |
)
|
| 50 |
|
| 51 |
+
# FIX: Return both the token AND the Livekit URL from the environment
|
| 52 |
+
return flask.jsonify({
|
| 53 |
+
"token": token.to_jwt(),
|
| 54 |
+
"livekitUrl": LIVEKIT_URL
|
| 55 |
+
})
|
| 56 |
|
| 57 |
|
| 58 |
def run_agent_worker():
|
| 59 |
"""
|
| 60 |
Runs the app.py agent worker in a subprocess.
|
|
|
|
| 61 |
"""
|
| 62 |
print("Starting Livekit Agent worker...")
|
| 63 |
|
|
|
|
| 64 |
agent_token = (
|
| 65 |
api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET)
|
| 66 |
.with_identity("devraze-agent")
|
| 67 |
.with_name("DevRaze")
|
|
|
|
| 68 |
.with_grants(api.VideoGrants(room_join=True, room=ROOM_NAME, room_admin=True, hidden=True))
|
| 69 |
)
|
| 70 |
|
|
|
|
|
|
|
|
|
|
| 71 |
command = [
|
| 72 |
"livekit-agent",
|
| 73 |
"run-agent",
|
|
|
|
| 79 |
]
|
| 80 |
|
| 81 |
try:
|
| 82 |
+
# Using Popen instead of run so it doesn't block, but streams output
|
| 83 |
+
process = subprocess.Popen(
|
| 84 |
+
command,
|
| 85 |
+
stdout=subprocess.PIPE,
|
| 86 |
+
stderr=subprocess.STDOUT,
|
| 87 |
+
text=True
|
| 88 |
+
)
|
| 89 |
+
# Print agent logs to the main console
|
| 90 |
+
for line in process.stdout:
|
| 91 |
+
print(f"[AGENT] {line.strip()}")
|
| 92 |
+
|
| 93 |
except FileNotFoundError:
|
| 94 |
print("Error: 'livekit-agent' command not found.")
|
| 95 |
+
except Exception as e:
|
| 96 |
+
print(f"Error starting agent: {e}")
|
| 97 |
|
| 98 |
|
| 99 |
if __name__ == "__main__":
|
|
|
|
| 102 |
agent_thread.daemon = True
|
| 103 |
agent_thread.start()
|
| 104 |
|
| 105 |
+
# Run the Flask app
|
| 106 |
app.run(host="0.0.0.0", port=7860)
|
templates/index.html
CHANGED
|
@@ -60,6 +60,7 @@
|
|
| 60 |
padding: 10px;
|
| 61 |
border-radius: 4px;
|
| 62 |
transition: all 0.3s ease;
|
|
|
|
| 63 |
}
|
| 64 |
|
| 65 |
.status-disconnected {
|
|
@@ -103,23 +104,48 @@
|
|
| 103 |
color: #888;
|
| 104 |
cursor: not-allowed;
|
| 105 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</style>
|
| 107 |
</head>
|
| 108 |
<body>
|
| 109 |
<div class="container">
|
| 110 |
<h1>DevRaze, the Roastmaster AI</h1>
|
| 111 |
-
<p>Embedded in Rajesh Yarra’s portfolio. Ask me anything about him. I dare you
|
| 112 |
-
<div id="status" class="status-disconnected">Status:
|
| 113 |
<button id="connect-button">Connect Microphone</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
</div>
|
| 115 |
|
| 116 |
<script>
|
| 117 |
const connectButton = document.getElementById('connect-button');
|
| 118 |
const statusDiv = document.getElementById('status');
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
// This will be automatically provided by Livekit when you deploy
|
| 121 |
-
// For local dev, you'll set LIVEKIT_URL in your .env
|
| 122 |
-
const LIVEKIT_URL = `wss://${window.location.hostname}`;
|
| 123 |
const TOKEN_ENDPOINT = '/get-token';
|
| 124 |
|
| 125 |
let room = null;
|
|
@@ -135,49 +161,75 @@
|
|
| 135 |
|
| 136 |
connectButton.disabled = true;
|
| 137 |
connectButton.textContent = "Initializing...";
|
| 138 |
-
updateStatus('
|
| 139 |
|
| 140 |
try {
|
| 141 |
-
// Fetch
|
| 142 |
const resp = await fetch(TOKEN_ENDPOINT);
|
| 143 |
if (!resp.ok) {
|
| 144 |
-
throw new Error(`
|
| 145 |
}
|
| 146 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
// Create
|
| 149 |
room = new LivekitClient.Room({
|
| 150 |
audioCaptureDefaults: {
|
| 151 |
autoGainControl: true,
|
|
|
|
| 152 |
noiseSuppression: true,
|
| 153 |
},
|
|
|
|
|
|
|
| 154 |
});
|
| 155 |
|
| 156 |
-
//
|
| 157 |
room.on(LivekitClient.RoomEvent.Disconnected, () => {
|
| 158 |
console.log('Disconnected from room');
|
| 159 |
handleDisconnect();
|
| 160 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
-
// Connect to the
|
| 163 |
-
await room.connect(
|
| 164 |
console.log('Successfully connected to room', room.name);
|
| 165 |
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
await room.localParticipant.setMicrophoneEnabled(true);
|
| 168 |
console.log('Microphone enabled');
|
| 169 |
|
| 170 |
// Update UI for connected state
|
| 171 |
isConnected = true;
|
| 172 |
-
updateStatus('Connected.
|
| 173 |
connectButton.textContent = "Disconnect";
|
| 174 |
connectButton.disabled = false;
|
| 175 |
|
| 176 |
} catch (error) {
|
| 177 |
console.error('Connection failed:', error);
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
connectButton.textContent = "Connect Microphone";
|
| 180 |
connectButton.disabled = false;
|
|
|
|
| 181 |
}
|
| 182 |
}
|
| 183 |
|
|
@@ -190,6 +242,7 @@
|
|
| 190 |
updateStatus('Disconnected', 'status-disconnected');
|
| 191 |
connectButton.textContent = "Connect Microphone";
|
| 192 |
connectButton.disabled = false;
|
|
|
|
| 193 |
}
|
| 194 |
|
| 195 |
connectButton.addEventListener('click', () => {
|
|
@@ -199,6 +252,44 @@
|
|
| 199 |
connectToRoom();
|
| 200 |
}
|
| 201 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
</script>
|
| 204 |
</body>
|
|
|
|
| 60 |
padding: 10px;
|
| 61 |
border-radius: 4px;
|
| 62 |
transition: all 0.3s ease;
|
| 63 |
+
word-break: break-word; /* Prevent long errors from breaking layout */
|
| 64 |
}
|
| 65 |
|
| 66 |
.status-disconnected {
|
|
|
|
| 104 |
color: #888;
|
| 105 |
cursor: not-allowed;
|
| 106 |
}
|
| 107 |
+
|
| 108 |
+
/* Visualizer bars (optional enhancement) */
|
| 109 |
+
#visualizer {
|
| 110 |
+
display: flex;
|
| 111 |
+
justify-content: center;
|
| 112 |
+
align-items: center;
|
| 113 |
+
height: 30px;
|
| 114 |
+
margin-top: 20px;
|
| 115 |
+
gap: 3px;
|
| 116 |
+
opacity: 0;
|
| 117 |
+
transition: opacity 0.5s;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.bar {
|
| 121 |
+
width: 4px;
|
| 122 |
+
background-color: var(--secondary-color);
|
| 123 |
+
border-radius: 2px;
|
| 124 |
+
height: 5px;
|
| 125 |
+
transition: height 0.1s ease;
|
| 126 |
+
}
|
| 127 |
</style>
|
| 128 |
</head>
|
| 129 |
<body>
|
| 130 |
<div class="container">
|
| 131 |
<h1>DevRaze, the Roastmaster AI</h1>
|
| 132 |
+
<p>Embedded in Rajesh Yarra’s portfolio. Ask me anything about him. I dare you.</p>
|
| 133 |
+
<div id="status" class="status-disconnected">Status: Ready to roast.</div>
|
| 134 |
<button id="connect-button">Connect Microphone</button>
|
| 135 |
+
|
| 136 |
+
<!-- Simple visualizer -->
|
| 137 |
+
<div id="visualizer">
|
| 138 |
+
<div class="bar"></div><div class="bar"></div><div class="bar"></div>
|
| 139 |
+
<div class="bar"></div><div class="bar"></div>
|
| 140 |
+
</div>
|
| 141 |
</div>
|
| 142 |
|
| 143 |
<script>
|
| 144 |
const connectButton = document.getElementById('connect-button');
|
| 145 |
const statusDiv = document.getElementById('status');
|
| 146 |
+
const visualizer = document.getElementById('visualizer');
|
| 147 |
+
const bars = document.querySelectorAll('.bar');
|
| 148 |
|
|
|
|
|
|
|
|
|
|
| 149 |
const TOKEN_ENDPOINT = '/get-token';
|
| 150 |
|
| 151 |
let room = null;
|
|
|
|
| 161 |
|
| 162 |
connectButton.disabled = true;
|
| 163 |
connectButton.textContent = "Initializing...";
|
| 164 |
+
updateStatus('Getting credentials...', 'status-connecting');
|
| 165 |
|
| 166 |
try {
|
| 167 |
+
// 1. Fetch token and Livekit URL from our server
|
| 168 |
const resp = await fetch(TOKEN_ENDPOINT);
|
| 169 |
if (!resp.ok) {
|
| 170 |
+
throw new Error(`Server error: ${resp.statusText}`);
|
| 171 |
}
|
| 172 |
+
const data = await resp.json();
|
| 173 |
+
|
| 174 |
+
if (!data.token || !data.livekitUrl) {
|
| 175 |
+
throw new Error("Server didn't return token or URL");
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
updateStatus('Connecting to Livekit...', 'status-connecting');
|
| 179 |
|
| 180 |
+
// 2. Create the room
|
| 181 |
room = new LivekitClient.Room({
|
| 182 |
audioCaptureDefaults: {
|
| 183 |
autoGainControl: true,
|
| 184 |
+
echoCancellation: true,
|
| 185 |
noiseSuppression: true,
|
| 186 |
},
|
| 187 |
+
adaptiveStream: true,
|
| 188 |
+
dynacast: true,
|
| 189 |
});
|
| 190 |
|
| 191 |
+
// Setup Room Event Listeners
|
| 192 |
room.on(LivekitClient.RoomEvent.Disconnected, () => {
|
| 193 |
console.log('Disconnected from room');
|
| 194 |
handleDisconnect();
|
| 195 |
});
|
| 196 |
+
|
| 197 |
+
// Handle incoming audio from the Agent
|
| 198 |
+
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
| 199 |
+
if (track.kind === LivekitClient.Track.Kind.Audio) {
|
| 200 |
+
const element = track.attach();
|
| 201 |
+
document.body.appendChild(element);
|
| 202 |
+
startVisualizer(track); // Optional: start simple visualizer
|
| 203 |
+
}
|
| 204 |
+
});
|
| 205 |
|
| 206 |
+
// 3. Connect to Livekit Cloud using the data from server
|
| 207 |
+
await room.connect(data.livekitUrl, data.token);
|
| 208 |
console.log('Successfully connected to room', room.name);
|
| 209 |
|
| 210 |
+
updateStatus('Activating microphone...', 'status-connecting');
|
| 211 |
+
|
| 212 |
+
// 4. Publish local microphone
|
| 213 |
await room.localParticipant.setMicrophoneEnabled(true);
|
| 214 |
console.log('Microphone enabled');
|
| 215 |
|
| 216 |
// Update UI for connected state
|
| 217 |
isConnected = true;
|
| 218 |
+
updateStatus('Connected. Prepare to be roasted.', 'status-connected');
|
| 219 |
connectButton.textContent = "Disconnect";
|
| 220 |
connectButton.disabled = false;
|
| 221 |
|
| 222 |
} catch (error) {
|
| 223 |
console.error('Connection failed:', error);
|
| 224 |
+
// Make error readable
|
| 225 |
+
let errorMsg = error.message;
|
| 226 |
+
if (errorMsg.includes('could not establish signal connection')) {
|
| 227 |
+
errorMsg = "Could not connect to Livekit Cloud. Check server logs.";
|
| 228 |
+
}
|
| 229 |
+
updateStatus(`Error: ${errorMsg}`, 'status-disconnected');
|
| 230 |
connectButton.textContent = "Connect Microphone";
|
| 231 |
connectButton.disabled = false;
|
| 232 |
+
handleDisconnect(); // Ensure cleanup
|
| 233 |
}
|
| 234 |
}
|
| 235 |
|
|
|
|
| 242 |
updateStatus('Disconnected', 'status-disconnected');
|
| 243 |
connectButton.textContent = "Connect Microphone";
|
| 244 |
connectButton.disabled = false;
|
| 245 |
+
stopVisualizer();
|
| 246 |
}
|
| 247 |
|
| 248 |
connectButton.addEventListener('click', () => {
|
|
|
|
| 252 |
connectToRoom();
|
| 253 |
}
|
| 254 |
});
|
| 255 |
+
|
| 256 |
+
// --- Simple Audio Visualizer (Optional) ---
|
| 257 |
+
let audioContext, analyser, dataArray, visualizerFrame;
|
| 258 |
+
|
| 259 |
+
function startVisualizer(track) {
|
| 260 |
+
visualizer.style.opacity = 1;
|
| 261 |
+
if (!audioContext) {
|
| 262 |
+
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
| 263 |
+
}
|
| 264 |
+
const stream = new MediaStream([track.mediaStreamTrack]);
|
| 265 |
+
const source = audioContext.createMediaStreamSource(stream);
|
| 266 |
+
analyser = audioContext.createAnalyser();
|
| 267 |
+
analyser.fftSize = 32;
|
| 268 |
+
source.connect(analyser);
|
| 269 |
+
dataArray = new Uint8Array(analyser.frequencyBinCount);
|
| 270 |
+
animateVisualizer();
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
function animateVisualizer() {
|
| 274 |
+
if (!isConnected || !analyser) return;
|
| 275 |
+
analyser.getByteFrequencyData(dataArray);
|
| 276 |
+
|
| 277 |
+
// Map frequency data to the 5 bars
|
| 278 |
+
const indices = [1, 3, 5, 7, 9]; // Pick some frequencies
|
| 279 |
+
bars.forEach((bar, i) => {
|
| 280 |
+
const value = dataArray[indices[i]] || 0;
|
| 281 |
+
const height = Math.max(5, (value / 255) * 30);
|
| 282 |
+
bar.style.height = `${height}px`;
|
| 283 |
+
});
|
| 284 |
+
visualizerFrame = requestAnimationFrame(animateVisualizer);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
function stopVisualizer() {
|
| 288 |
+
visualizer.style.opacity = 0;
|
| 289 |
+
if (visualizerFrame) cancelAnimationFrame(visualizerFrame);
|
| 290 |
+
// Reset bars
|
| 291 |
+
bars.forEach(bar => bar.style.height = '5px');
|
| 292 |
+
}
|
| 293 |
|
| 294 |
</script>
|
| 295 |
</body>
|