iago
This commit is contained in:
parent
b403c6d1d4
commit
1d6a73676b
15
packages/iago/README.md
Normal file
15
packages/iago/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# iago
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.13. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
||||
25
packages/iago/bun.lock
Normal file
25
packages/iago/bun.lock
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "iago",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
|
||||
|
||||
"@types/node": ["@types/node@24.0.3", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
}
|
||||
}
|
||||
16
packages/iago/package.json
Normal file
16
packages/iago/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "@workshop/iago",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --hot server.ts",
|
||||
"start": "bun run server.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
BIN
packages/iago/photo.jpg
Normal file
BIN
packages/iago/photo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 383 KiB |
263
packages/iago/public/talk.html
Normal file
263
packages/iago/public/talk.html
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Voice Recorder</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.record-button {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #ff4757;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.record-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 10px 20px rgba(255, 71, 87, 0.3);
|
||||
}
|
||||
|
||||
.record-button.recording {
|
||||
background: #2ed573;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 20px 0;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status.recording {
|
||||
background: #ffeaa7;
|
||||
color: #d63031;
|
||||
}
|
||||
|
||||
.status.ready {
|
||||
background: #55a3ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transcribe-button {
|
||||
background: #3742fa;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 15px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.transcribe-button:hover {
|
||||
background: #2f3542;
|
||||
}
|
||||
|
||||
.transcribe-button:disabled {
|
||||
background: #ddd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.transcription {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Voice Recorder</h1>
|
||||
|
||||
<button id="recordButton" class="record-button">
|
||||
🎙️
|
||||
</button>
|
||||
|
||||
<div id="status" class="status ready">
|
||||
Click the mic to start recording
|
||||
</div>
|
||||
|
||||
<audio id="audioPlayer" class="audio-player hidden" controls></audio>
|
||||
|
||||
<button id="transcribeButton" class="transcribe-button hidden">
|
||||
Transcribe Audio
|
||||
</button>
|
||||
|
||||
<div id="transcription" class="transcription hidden">
|
||||
<strong>Transcription:</strong><br>
|
||||
<span id="transcriptionText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let mediaRecorder;
|
||||
let audioChunks = [];
|
||||
let isRecording = false;
|
||||
|
||||
const recordButton = document.getElementById('recordButton');
|
||||
const status = document.getElementById('status');
|
||||
const audioPlayer = document.getElementById('audioPlayer');
|
||||
const transcribeButton = document.getElementById('transcribeButton');
|
||||
const transcription = document.getElementById('transcription');
|
||||
const transcriptionText = document.getElementById('transcriptionText');
|
||||
|
||||
recordButton.addEventListener('click', toggleRecording);
|
||||
|
||||
async function toggleRecording() {
|
||||
if (!isRecording) {
|
||||
await startRecording();
|
||||
} else {
|
||||
stopRecording();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
audioChunks.push(event.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
audioPlayer.src = audioUrl;
|
||||
audioPlayer.classList.remove('hidden');
|
||||
transcribeButton.classList.remove('hidden');
|
||||
|
||||
// Store the blob for transcription
|
||||
window.audioBlob = audioBlob;
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecording = true;
|
||||
|
||||
recordButton.classList.add('recording');
|
||||
recordButton.textContent = '⏹️';
|
||||
status.textContent = 'Recording... Click to stop';
|
||||
status.className = 'status recording';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error);
|
||||
status.textContent = 'Error: Could not access microphone';
|
||||
status.className = 'status recording';
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop();
|
||||
mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
isRecording = false;
|
||||
recordButton.classList.remove('recording');
|
||||
recordButton.textContent = '🎙️';
|
||||
status.textContent = 'Recording complete!';
|
||||
status.className = 'status ready';
|
||||
}
|
||||
}
|
||||
|
||||
transcribeButton.addEventListener('click', async () => {
|
||||
if (!window.audioBlob) {
|
||||
alert('No audio to transcribe');
|
||||
return;
|
||||
}
|
||||
|
||||
transcribeButton.disabled = true;
|
||||
transcribeButton.textContent = 'Transcribing...';
|
||||
transcription.classList.remove('hidden');
|
||||
transcriptionText.textContent = 'Processing...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', window.audioBlob, 'recording.webm');
|
||||
|
||||
const response = await fetch('/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Transcription failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
transcriptionText.textContent = result.transcription || 'No transcription available';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Transcription error:', error);
|
||||
transcriptionText.textContent = 'Error: Could not transcribe audio';
|
||||
} finally {
|
||||
transcribeButton.disabled = false;
|
||||
transcribeButton.textContent = 'Transcribe Audio';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
146
packages/iago/server.ts
Normal file
146
packages/iago/server.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { Hono } from "hono"
|
||||
import { openai, createFile } from "./src/openai"
|
||||
import { serveStatic } from "hono/bun"
|
||||
import fs from "fs"
|
||||
|
||||
const CAMERA = process.env.IAGO_CAMERA || "Set IAGO_CAMERA environment variable"
|
||||
const PROMPT = "What text do you see in this image?"
|
||||
|
||||
const IMAGE_PATH = "./photo.jpg"
|
||||
const app = new Hono()
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use("/*", serveStatic({ root: "./public" }))
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.json({ message: "Hello World" })
|
||||
})
|
||||
|
||||
// you know
|
||||
app.get("/status", async (c) => {
|
||||
if (!(await checkImagesnap()))
|
||||
return c.json({ status: "imagesnap not found", devices: [], camera: CAMERA })
|
||||
|
||||
const devices = await getDevices()
|
||||
return c.json({ devices, camera: CAMERA, status: devices.includes(CAMERA) ? "camera found" : "camera not found" })
|
||||
})
|
||||
|
||||
// take a picture with the camera
|
||||
app.get("/capture", async (c) => {
|
||||
try {
|
||||
await runImagesnap()
|
||||
const image = await Bun.file(IMAGE_PATH).arrayBuffer()
|
||||
return new Response(image, {
|
||||
headers: { "Content-Type": "image/jpeg" },
|
||||
})
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err.message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// capture and analyze image
|
||||
app.get("/analyze", async (c) => {
|
||||
try {
|
||||
// await runImagesnap()
|
||||
|
||||
const fileId = await createFile(IMAGE_PATH)
|
||||
|
||||
const result = await openai.responses.create({
|
||||
model: "gpt-4o",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: PROMPT },
|
||||
{ type: "input_image", file_id: fileId, detail: "high" }
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return c.json({ result: result.output_text })
|
||||
} catch (err: any) {
|
||||
return c.json({ error: err.message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
app.get("/speak", async (c) => {
|
||||
const mp3 = await openai.audio.speech.create({
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "ash",
|
||||
instructions: "Speak in an upbeat and goofy tone!",
|
||||
input: "I put the MAN in MAN-UH!"
|
||||
})
|
||||
|
||||
const buffer = Buffer.from(await mp3.arrayBuffer())
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": "audio/mpeg",
|
||||
"Content-Disposition": "inline; filename=speak.mp3"
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/talk", async (c) => {
|
||||
return c.redirect("/talk.html")
|
||||
})
|
||||
|
||||
app.post("/transcribe", async (c) => {
|
||||
try {
|
||||
const formData = await c.req.formData()
|
||||
const audioFile = formData.get("audio") as File
|
||||
|
||||
if (!audioFile) {
|
||||
return c.json({ error: "No audio file provided" }, 400)
|
||||
}
|
||||
|
||||
// Convert File to Buffer
|
||||
const arrayBuffer = await audioFile.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
// Create a temporary file
|
||||
const tempPath = `./temp_audio_${Date.now()}.webm`
|
||||
await Bun.write(tempPath, buffer)
|
||||
|
||||
const transcription = await openai.audio.transcriptions.create({
|
||||
file: Bun.file(tempPath),
|
||||
model: "gpt-4o-transcribe",
|
||||
response_format: "text",
|
||||
})
|
||||
|
||||
// Clean up temporary file
|
||||
await fs.promises.unlink(tempPath)
|
||||
|
||||
console.log(transcription)
|
||||
return c.json({ transcription })
|
||||
} catch (err: any) {
|
||||
console.error("Transcription error:", err)
|
||||
return c.json({ error: err.message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// Capture image using Bun.spawn
|
||||
async function runImagesnap(): Promise<void> {
|
||||
const proc = await $`imagesnap -d ${CAMERA} ${IMAGE_PATH}`
|
||||
if (proc.exitCode !== 0) throw new Error("imagesnap failed")
|
||||
}
|
||||
|
||||
// Check if imagesnap exists using Bun.spawnSync
|
||||
async function checkImagesnap(): Promise<boolean> {
|
||||
const result = await $`which imagesnap`
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
// Get devices using imagesnap
|
||||
async function getDevices(): Promise<string[]> {
|
||||
const result = await $`imagesnap -l`
|
||||
return result.stdout.toString().split("\n").filter(line => line.startsWith("=> ")).map(line => line.replace("=> ", ""))
|
||||
}
|
||||
|
||||
export default {
|
||||
port: 3000,
|
||||
fetch: app.fetch,
|
||||
}
|
||||
16
packages/iago/src/openai.ts
Normal file
16
packages/iago/src/openai.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import OpenAI from "openai"
|
||||
|
||||
export const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY || "",
|
||||
})
|
||||
|
||||
|
||||
// Function to create a file with the Files API
|
||||
export async function createFile(filePath: string) {
|
||||
const fileContent = Bun.file(filePath)
|
||||
const result = await openai.files.create({
|
||||
file: fileContent,
|
||||
purpose: "vision",
|
||||
})
|
||||
return result.id
|
||||
}
|
||||
BIN
packages/iago/test.jpg
Normal file
BIN
packages/iago/test.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 383 KiB |
28
packages/iago/tsconfig.json
Normal file
28
packages/iago/tsconfig.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user