Compare commits

..

3 Commits

Author SHA1 Message Date
MikeHughes-BIN 3053674888 documents storage folder for generated documents 2025-11-11 00:27:11 +01:00
MikeHughes-BIN 6c7bd4661a Modified the description 2025-11-11 00:12:29 +01:00
MikeHughes-BIN e7271f216a Developed an API integration for Google Gemini AI to automatically generate Markdown documentation from transcripts. 2025-11-11 00:11:42 +01:00
36 changed files with 993 additions and 1292 deletions
+1 -5
View File
@@ -964,8 +964,4 @@ app.*.symbols
!/dev/ci/**/Gemfile.lock !/dev/ci/**/Gemfile.lock
#Storage files #Storage files
storage/ storage/
*.mp4
*.wav
*.flac
+2 -23
View File
@@ -88,27 +88,6 @@ Show your appreciation to those who have contributed to the project.
## License ## License
For open source projects, say how it is licensed. For open source projects, say how it is licensed.
---
## Sprint 4 Secure API Key Management ## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
In Sprint 4, secure handling of API keys was implemented for the V2D (Video to Document) framework.
### Implementation Overview
- API keys are **not stored in the source code**
- The backend loads the key from an **environment variable**
- A single configuration works for all users without manual setup
- Secrets are protected from being exposed in the repository or frontend
### Configuration
The backend expects the following environment variable:
This variable is injected at runtime by the deployment or CI/CD environment and referenced in `application.yml`.
### Security Benefits
- Prevents accidental exposure of API keys
- Ensures secure collaboration in GitLab
- Follows best practices for secret management
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

+9 -16
View File
@@ -3,22 +3,15 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="title">Video to document</title> <title>Video Upload Drag and Drop + Button</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<div class="mitte" id="mitte"> <div class="mitte" id="mitte">
<div class="flagsBtns" id="flagsBtns"> <h1>Video to document</h1>
<button class="de_Btn" id="de_Btn" onclick="changeLanguage('de')"><img src="flags/germany-flag-png-large.jpg" width="25px" height="20px"/></button>
<button class="eng_Btn" id="eng_Btn" onclick="changeLanguage('en')"><img src="flags/united-kingdom-flag-png-large.jpg" width="25px" height="20px"/></button>
<button class="in_Btn" id="in_Btn" onclick="changeLanguage('in')"><img src="flags/india-flag-png-large.png" width="25px" height="20px"/></button>
</div>
<h1 id="h1">Video to document</h1>
<div class="upload-container" id="uploadContainer"> <div class="upload-container" id="uploadContainer">
<p id="p1">Drag and drop video file</p> <p>Drag and drop video file</p>
<div class="file-name" id="fileName">No video chosen</div> <div class="file-name" id="fileName">No video chosen</div>
</div> </div>
@@ -26,19 +19,19 @@
<input type="file" id="videoUpload" accept="video/*"> <input type="file" id="videoUpload" accept="video/*">
<div class="checkbox-group"> <div class="checkbox-group">
<label id="checkbox_group" for="checkbox-group">Choose prefered document style:</label> <label for="checkbox-group">Choose prefered document style:</label>
<div class="checkbox-container"> <div class="checkbox-container">
<input type="checkbox" name ="docFormat" id="docFormat"> <input type="checkbox" id="docFormat">
<label id="label_format" for="docFormat">Meeting report</label> <label for "docFormat">Meeting report</label>
</div> </div>
<div class="checkbox-container"> <div class="checkbox-container">
<input type="checkbox" name="docFormat" id="docFormatSummary"> <input type="checkbox" id="docFormatCustom">
<label id="label_summary" for="docFormatSummary">Summary with timestamps</label> <label for "docFormatCustom">Summary with timestamps</label>
</div> </div>
</div> </div>
<button class="submit-btn" id="submitButton" onclick="checkBoxes()">Submit</button> <button class="submit-btn" id="submitButton">Submit Video</button>
<div class="progressbar"> <div class="progressbar">
<div class="progress_fill"></div> <div class="progress_fill"></div>
+3 -14
View File
@@ -2,17 +2,6 @@
const { contextBridge, ipcRenderer, webUtils } = require('electron') const { contextBridge, ipcRenderer, webUtils } = require('electron')
try { contextBridge.exposeInMainWorld("explorer", {
contextBridge.exposeInMainWorld("explorer", { onFileDrop: (file) => webUtils.getPathForFile(file)
onFileDrop: (file) => webUtils.getPathForFile(file) })
})
contextBridge.exposeInMainWorld("extractor", {
extract: (file) => ipcRenderer.send("extract", file)
})
contextBridge.exposeInMainWorld("electronAPI", {
getFilePath: (file) => {return webUtils.getPathForFile(file)}
})
} catch (error) {
console.log("Error in preload.js");
}
+15 -24
View File
@@ -1,31 +1,22 @@
uploadContainer.addEventListener("dragover", (e) =>{ const dropzone = document.getElementById("uploadContainer");
try {
e.stopPropagation(); dropzone.addEventListener("dragover", (e) =>{
e.preventDefault(); e.stopPropagation();
} catch (error) { e.preventDefault();
console.log("Error in renderer.js dragover listener function")
}
}); });
//listener for when a file get dropped on the drag&drop field dropzone.addEventListener("drop", (e) => {
uploadContainer.addEventListener("drop", (e) => { e.stopPropagation()
try { e.preventDefault()
e.stopPropagation() const files = e.dataTransfer.files
e.preventDefault() const filePath = window.explorer.onFileDrop(files[0])
const files = e.dataTransfer.files var holdy = filePath + "";
const filePath = window.explorer.onFileDrop(files[0]) if(holdy.endsWith(".mp4")){
var holdy = filePath + ""; console.log(filePath)
if(holdy.endsWith(".mp4") || holdy.endsWith(".mov") || holdy.endsWith(".avi") || holdy.endsWith( ".mkv")){
console.log(filePath)
const files1 = e.dataTransfer.files; const files1 = e.dataTransfer.files;
handleFiles(files1); handleFiles(files1);
}
} catch (error) {
console.log("Error in renderer.js with the listerner for the drop function");
} }
}) })
+23 -101
View File
@@ -1,115 +1,37 @@
const uploadContainer = document.getElementById('uploadContainer');
const fileInput = document.getElementById('videoUpload');
const fileName = document.getElementById('fileName');
const manualBtn = document.getElementById('manualUploadBtn');
const videoPreview = document.getElementById('videoPreview');
//listener for the file explorer search
manualUploadBtn.addEventListener('click', () => {
try {
videoUpload.click();
} catch (error) { manualBtn.addEventListener('click', () => {
console.log("Error in manualBtn EventListener click"); fileInput.click();
}
}); });
//function to check if one checkbox is at least klicked
function checkBoxes() {
try {
const checkboxes = document.querySelectorAll('input[name="docFormat"]');
let isChecked = false;
checkboxes.forEach(function(checkbox){ fileInput.addEventListener('change', () => {
if(checkbox.checked){ handleFiles(fileInput.files);
isChecked = true;
}
});
if(isChecked){
//Code to submit the video
var pathTest = window.electronAPI.getFilePath(videoUpload.files[0]);
if(pathTest.endsWith(".mp4") || holdy.endsWith(".mov") || holdy.endsWith(".avi") || holdy.endsWith( ".mkv")){
window.extractor.extract({inputVideoPath: pathTest, outputType:"wav"})
}
} else {
//language only english at the moment
alert('Please select at least one document type.');
}
} catch (error) {
console.log(error)
}
// mapFunctions.get("extraction-video-to-audio").function({inputVideoPath:"./a.mp4", outputType:"wav"})
}
//language changing feature
function changeLanguage(language) {
if (language === 'en') {
document.getElementById('title').textContent = 'Video to document';
document.getElementById('h1').textContent = 'Video to document';
document.getElementById('p1').textContent = 'Drag and drop video file';
document.getElementById('fileName').textContent = 'No video chosen';
document.getElementById('manualUploadBtn').textContent = 'Search video';
document.getElementById('checkbox_group').textContent = 'Choose prefered document style:';
document.getElementById('label_format').textContent = 'Meeting report';
document.getElementById('label_summary').textContent = 'Summary with timestamps';
document.getElementById('submitButton').textContent = 'Submit';
} else if (language === 'de') {
document.getElementById('title').textContent = 'Video zu Dokument';
document.getElementById('h1').textContent = 'Video zu Dokument';
document.getElementById('p1').textContent = 'Video per Drag & Drop ablegen';
document.getElementById('fileName').textContent = 'Kein Video ausgewaehlt';
document.getElementById('manualUploadBtn').textContent = 'Video suchen';
document.getElementById('checkbox_group').textContent = 'Bevorzugte Dokumentvarianten:';
document.getElementById('label_format').textContent = 'Meeting Bericht';
document.getElementById('label_summary').textContent = 'Zusammenfassung mit Zeitstempeln';
document.getElementById('submitButton').textContent = 'Absenden';
} else if(language == "in") {
document.getElementById('title').textContent = 'दस्तावेज़ के लिए वीडियो';
document.getElementById('h1').textContent = 'दस्तावेज़ के लिए वीडियो';
document.getElementById('p1').textContent = 'वीडियो फ़ाइल खींचें और छोड़ें';
document.getElementById('fileName').textContent = 'कोई वीडियो नहीं चुना गया';
document.getElementById('manualUploadBtn').textContent = 'वीडियो खोजें';
document.getElementById('checkbox_group').textContent = 'पसंदीदा दस्तावेज़ शैली चुनें:';
document.getElementById('label_format').textContent = 'बैठक रिपोर्ट';
document.getElementById('label_summary').textContent = 'टाइमस्टैम्प के साथ सारांश';
document.getElementById('submitButton').textContent = 'जमा करना';
}
}
//listener for the file explorer search when something got selected
videoUpload.addEventListener('change', () => {
try {
handleFiles(videoUpload.files);
} catch (error) {
console.log("Error in manualBtn EventListener change");
}
}); });
//function to display the file path in the drop down box
function handleFiles(files) { function handleFiles(files) {
try { if (files.length > 0) {
if (files.length > 0) { const file = files[0];
const file = files[0]; if (file.type.startsWith('video/')) {
if (file.type.startsWith('video/')) { fileInput.files = files;
videoUpload.files = files; fileName.textContent = `Chosen video: ${file.name}`;
fileName.textContent = `Chosen video: ${file.name}`;
}
} }
} catch (error) { }
console.log("Error in script.js handleFiles function");
}
} }
//function to regulate the progress on the progressbar
function updateProgressBar(bar, value){ function updateProgressBar(bar, value){
try { value = Math.round(value);
value = Math.round(value); bar.querySelector(".progress_fill").style.width = `${value}%`;
bar.querySelector(".progress_fill").style.width = `${value}%`; bar.querySelector(".progress_text").textContent = `${value}%`;
bar.querySelector(".progress_text").textContent = `${value}%`;
} catch (error) {
console.log("Error in scripts.js updateProgressBar function");
}
} }
+4 -19
View File
@@ -5,7 +5,7 @@ body {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100vh; height: 100vh;
background-color: #555; background-color: #444;
gap: 15px; gap: 15px;
margin: 0; margin: 0;
} }
@@ -75,7 +75,6 @@ gap: 5px;
.checkbox-group { .checkbox-group {
margin-top: 15px; margin-top: 15px;
margin-bottom: 15px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -93,14 +92,14 @@ gap: 5px;
} }
.mitte { .mitte {
background-color: #f2f3f4; background-color: #eaf0ff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: 5% 50px; padding: 5% 50px;
margin-top: 20px; margin-top: 20px;
gap: 10px; gap: 10px;
border: 1px; border: 2px;
border-color: black; border-color: black;
border-style: solid; border-style: solid;
} }
@@ -113,7 +112,7 @@ h1 {
position: relative; position: relative;
width: 210px; width: 210px;
height: 30px; height: 30px;
background: rgb(42, 46, 78); background: darkslategray;
border-radius: 5px; border-radius: 5px;
overflow: hidden; overflow: hidden;
} }
@@ -130,18 +129,4 @@ h1 {
top: 50%; top: 50%;
right: 5px; right: 5px;
transform: translateY(-50%); transform: translateY(-50%);
color: white;
}
.flagsBtns {
display: flex;
justify-content: flex-end;
}
.de_Btn, .eng_Btn, .in_Btn {
padding: 8px 16px;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
} }
+1 -22
View File
@@ -55,25 +55,4 @@ rl.on("line", data =>{
// ----------------------------------------------------------- ELECTRON ----------------------------------------------------------- // // ----------------------------------------------------------- ELECTRON ----------------------------------------------------------- //
let mainWindow; // TODO - Add Electron support to the project
function createWindow() {
mainWindow = new electron.BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: `${mainDir}/electron/main/preload.js`
}
});
mainWindow.loadFile('./electron/main/index.html');
}
electron.app.whenReady().then(createWindow);
electron.ipcMain.on("extract", (event, args) => {
mapFunctions.get("extraction-video-to-audio").function(args)
})
+763 -603
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -1,9 +1,8 @@
{ {
"type": "module",
"dependencies": { "dependencies": {
"@types/axios": "^0.9.36", "@google/genai": "^1.29.0",
"cli-progress": "^3.12.0", "cli-progress": "^3.12.0",
"dotenv": "^17.2.3",
"electron": "^39.1.1",
"express": "^5.1.0", "express": "^5.1.0",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3" "fluent-ffmpeg": "^2.1.3"
@@ -24,7 +23,6 @@
"test": "tests" "test": "tests"
}, },
"scripts": { "scripts": {
"start": "electron main.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"repository": { "repository": {
-8
View File
@@ -8,11 +8,3 @@ fs = require("fs")
readline = require("readline") readline = require("readline")
config = require("./config/config") config = require("./config/config")
ffmpegPath = require('ffmpeg-static');
ffmpeg = require('fluent-ffmpeg');
path = require('path');
cliProgress = require('cli-progress');
// { app, BrowserWindow, ipcMain, dialog } = require('electron');
electron = require('electron');
+5
View File
@@ -0,0 +1,5 @@
npx ts-node ./extract.ts /Users/mikehughes/Downloads/Testvideo/Kurzgesagt.mov
npx ts-node ./transcribe.ts ../storage/audio/Kurzgesagt.wav
npx ts-node ./extract.ts /Users/mikehughes/Downloads/Testvideo/GitLabMeeting.mov
npx ts-node ./transcribe.ts ../storage/audio/GitLabMeeting.wav
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env ts-node
import { extractAudioFromVideo } from "../services/modules/extraction/ffmpegExtractor.ts";
const videoPath = process.argv[2];
if (!videoPath) {
console.error("Usage: ts-node extractAudio.ts <videoPath>");
process.exit(1);
}
(async () => {
try {
console.log(`Extracting audio from: ${videoPath}`);
await extractAudioFromVideo(videoPath); // Call the extraction function (ffmpegExtractor.ts in services/modules/extraction)
console.log("Audio extraction completed successfully.");
} catch (err) {
console.error("Audio extraction failed:", err);
process.exit(1);
}
})();
+18
View File
@@ -0,0 +1,18 @@
import { whisperLocal } from "../services/modules/transcription/local/whisperLocal.ts";
const audioPath = process.argv[2];
if (!audioPath) {
console.error("Please provide an audio file path as argument.");
process.exit(1);
}
const whisper = new whisperLocal();
(async () => {
try {
const text = await whisper.transcribe(audioPath);
console.log(text);
} catch (err) {
console.error("Transcription failed:", err);
}
})();
@@ -0,0 +1,31 @@
import { GoogleGenAI } from "@google/genai";
import fs from "fs";
// Mike Hughes 10/11/25 - Code inspired from
// https://www.geeksforgeeks.org/javascript/javascript-program-to-read-text-file/ for reading text files in JS
// and from https://ai.google.dev/gemini-api/docs/quickstart?hl=de for using Gemini API
// Before using, make sure to export the Google Gemini API key as an environment variable:
// export GOOGLE_API_KEY="your_api_key_here"
// Keys can be obtained from https://aistudio.google.com/app/api-keys
// Also make sure to install the package via sudo npm install @google/genai
const ai = new GoogleGenAI({});
let transcript = "";
async function main() {
try {
const transcript = await fs.promises.readFile('../../../../storage/transcripts/Kurzgesagt.txt', 'utf-8');
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: `Create a short documentation about the content of the following transcript:\n\n${transcript}`,
});
console.log(response.text);
fs.writeFileSync("../../../../storage/documents/Kurzgesagt_documentation.md", response.text, "utf8");
} catch (error) {
console.error('Error generating content:', error);
}
}
main();
@@ -1,93 +0,0 @@
// Ensure ffmpeg binary is available
if (!ffmpegPath) {
throw new Error('FFmpeg binary not found!');
}
ffmpeg.setFfmpegPath(ffmpegPath);
// Prepare output directory (always storage/audio under project root)
const outputDir = `${__dirname}/../../../storage/audio`;
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
module.exports = {
name:"extraction-video-to-audio", // Unique name for our function that will later be used to get the function from the map via "mapFunctions.get("example").function()"
type:"extractor", // value used to differentiate each module to order them in the UI
displayname:"Default extractor", // The displayname used within the UI
async function(parameter){
/*
parameter structure:
{
inputVideoPath: String, // Path to the file
outputType: String // Audio file output format
}
*/
let progressBar = new cliProgress.SingleBar({
format: 'Processing |{bar}| {percentage}% | {timemark}',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true
});
try {
// if (meta.url === `file://${process.argv[1]}`) {
this.extractAudioFromVideo(parameter.inputVideoPath, progressBar, parameter.outputType)
.then(() => console.log('Audio extraction successful.'))
.catch((err) => console.error(err));
// }
} catch (error) {
console.log(parameter.outputType);
}
},
// Derive input and output paths
// const inputVideoPath = process.argv[2];
// console.log(process.argv);
/**
* Extracts audio from a video using ffmpeg.
* - Converts video to WAV (16 kHz, Mono, PCM optional if needed)
* - Shows CLI progress bar
* - Handles errors gracefully (without errors)
*/
extractAudioFromVideo: async function (videoFilePath, progressBar, outputType){
let inputVideoName = path.basename(videoFilePath, path.extname(videoFilePath));
let outputAudioPath = path.join(outputDir, `${inputVideoName}.${outputType}`);
return new Promise((resolve, reject) => {
try {
ffmpeg(videoFilePath)
.outputFormat(outputType)
// .audioCodec('pcm_s16le')
.audioChannels(1)
.audioFrequency(16000)
// .setFfmpegPath("./ffmpeg.exe")
.on('progress', (progress) => {
if (!progressBar.isActive) progressBar.start(100, 0, { timemark: '00:00:00' });
if (progress.percent) {
progressBar.update(progress.percent, { timemark: progress.timemark });
}
})
.on('end', () => {
progressBar.update(100, { timemark: 'done' });
progressBar.stop();
console.log(`Extraction completed: ${outputAudioPath}`);
resolve();
})
.on('error', (err) => {
progressBar.stop();
console.error(`failed_audio_extraction on type ${outputType}: ${err.message}`);
reject(err);
})
.save(outputAudioPath);
} catch (error) {
console.log();
}
});
}
}
@@ -0,0 +1,90 @@
import ffmpegPath from 'ffmpeg-static';
import ffmpeg from 'fluent-ffmpeg';
import path from 'path';
import fs from 'fs';
import cliProgress from 'cli-progress';
import { fileURLToPath } from 'url';
// Base code reference: https://docs.yemreak.com/arsiv/programming/extract-audio-from-video-with-typescript-and-ffmpeg
// Test command: npx ts-node ./extract.ts /path/to/video.mp4
/**
* Extracts audio from a video file and saves it as WAV.
* @param videoFilePath Path to the input video file.
*/
// Ensure ffmpeg binary is available
if (!ffmpegPath) {
throw new Error('FFmpeg binary not found!');
}
ffmpeg.setFfmpegPath(ffmpegPath);
// Ensure an input video path is provided via CLI
if (process.argv.length < 3) {
console.error('Usage: ts-node ./extract.ts <input-video-path>');
process.exit(1);
}
// Resolve __dirname equivalent in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Prepare output directory (always storage/audio under project root)
const outputDir = path.join(__dirname, '..', '..', '..', 'storage', 'audio');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Derive input and output paths
const inputVideoPath = process.argv[2];
const inputVideoName = path.basename(inputVideoPath, path.extname(inputVideoPath));
const outputAudioPath = path.join(outputDir, `${inputVideoName}.wav`);
// Initialize CLI progress bar
const progressBar = new cliProgress.SingleBar({
format: 'Processing |{bar}| {percentage}% | {timemark}',
barCompleteChar: '\u2588',
barIncompleteChar: '\u2591',
hideCursor: true
});
/**
* Extracts audio from a video using ffmpeg.
* - Converts video to WAV (16 kHz, Mono, PCM optional if needed)
* - Shows CLI progress bar
* - Handles errors gracefully (without errors)
*/
export function extractAudioFromVideo(videoFilePath: string): Promise<void> {
return new Promise((resolve, reject) => {
ffmpeg(videoFilePath)
.outputFormat('wav')
.audioCodec('pcm_s16le')
.audioChannels(1)
.audioFrequency(16000)
.on('progress', (progress) => {
if (!progressBar.isActive) progressBar.start(100, 0, { timemark: '00:00:00' });
if (progress.percent) {
progressBar.update(progress.percent, { timemark: progress.timemark });
}
})
.on('end', () => {
progressBar.update(100, { timemark: 'done' });
progressBar.stop();
console.log(`Extraction completed: ${outputAudioPath}`);
resolve();
})
.on('error', (err) => {
progressBar.stop();
console.error(`failed_audio_extraction: ${err.message}`);
reject(err);
})
.save(outputAudioPath);
});
}
// Run extraction if executed directly from CLI
if (import.meta.url === `file://${process.argv[1]}`) {
extractAudioFromVideo(inputVideoPath)
.then(() => console.log('Audio extraction successful.'))
.catch((err) => console.error(err));
}
@@ -1,133 +0,0 @@
import 'dotenv/config';
import axios from 'axios';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const API_KEY = process.env.ASSEMBLYAI_API_KEY;
const BASE_URL = 'https://api.assemblyai.com/v2';
/**
* Uploads audio file to AssemblyAI
*/
async function uploadAudio(audioPath: string): Promise<string> {
const audioData = fs.readFileSync(audioPath);
const response = await axios.post<{ upload_url: string }>(`${BASE_URL}/upload`, audioData, {
headers: {
'authorization': API_KEY,
'content-type': 'application/octet-stream'
}
});
return response.data.upload_url;
}
/**
* Extract a session id (basename without extension) from a local path or a URL
*/
function getSessionId(inputPath: string): string {
try {
const parsed = new URL(inputPath);
const base = path.basename(parsed.pathname);
return base.replace(/\.[^.]+$/, '');
} catch (err) {
// not a URL, treat as local path
return path.basename(inputPath, path.extname(inputPath));
}
}
/**
* Creates transcription job with speaker diarization
*/
async function createTranscript(audioUrl: string): Promise<string> {
const response = await axios.post<{ id: string }>(`${BASE_URL}/transcript`, {
audio_url: audioUrl,
speaker_labels: true,
language_detection: true
}, {
headers: {
'authorization': API_KEY,
'content-type': 'application/json'
}
});
return response.data.id;
}
/**
* Polls transcript status until completed
*/
async function pollTranscript(transcriptId: string): Promise<any> {
while (true) {
const response = await axios.get<any>(`${BASE_URL}/transcript/${transcriptId}`, {
headers: { 'authorization': API_KEY }
});
const status = response.data.status;
if (status === 'completed') {
return response.data;
} else if (status === 'error') {
throw new Error(`Transcription failed: ${response.data.error}`);
}
// Wait 3 seconds before next poll
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
/**
* Saves transcript to storage
*/
function saveTranscript(transcript: any, sessionId: string): void {
const outputDir = path.join(__dirname, '..', '..', '..', 'storage', 'transcripts');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const outputPath = path.join(outputDir, `${sessionId}.json`);
fs.writeFileSync(outputPath, JSON.stringify(transcript, null, 2));
console.log(`✅ Transcript saved: ${outputPath}`);
}
export default {
name: "assembly",
type: "transcription",
displayname: "AssemblyAI",
run: async (audioPath: string) => {
try {
// Determine if audioPath is an external URL or a local file
let audioUrl: string;
if (/^https?:\/\//i.test(audioPath)) {
console.log('🔗 Using external audio URL...');
audioUrl = audioPath;
} else {
console.log('🔄 Uploading local audio...');
if (!fs.existsSync(audioPath)) {
throw new Error(`Audio file not found: ${audioPath}`);
}
audioUrl = await uploadAudio(audioPath);
}
console.log('🔄 Creating transcript job...');
const transcriptId = await createTranscript(audioUrl);
console.log('⏳ Waiting for transcription...');
const transcript = await pollTranscript(transcriptId);
const sessionId = getSessionId(audioPath);
saveTranscript(transcript, sessionId);
return transcript;
} catch (error: any) {
console.error('❌ Transcription error:', error.message);
throw error;
}
}
};
Submodule services/modules/transcription/local/whisper.cpp added at 999a7e0cbf
@@ -5,7 +5,7 @@ import { fileURLToPath } from "url"; // To handle __dirname in ES modules
const __filename = fileURLToPath(import.meta.url); // Get current file path const __filename = fileURLToPath(import.meta.url); // Get current file path
const __dirname = path.dirname(__filename); // Get current directory path const __dirname = path.dirname(__filename); // Get current directory path
const transcriptsDir = path.resolve(__dirname, "../../../storage/transcriptions"); const transcriptsDir = path.resolve(__dirname, "../../storage/transcriptions");
export class whisperLocal { // is called by transcribe.ts export class whisperLocal { // is called by transcribe.ts
@@ -26,6 +26,7 @@ export class whisperLocal { // is called by transcribe.ts
async transcribe(audioPath: string): Promise<string> { //asyncronous function to transcribe audio async transcribe(audioPath: string): Promise<string> { //asyncronous function to transcribe audio
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const transcriptsDir = path.resolve(__dirname, "../../../../storage/transcripts"); //storage directory for transcripts
if (!fs.existsSync(transcriptsDir)) { //if transcripts directory does not exist, create it if (!fs.existsSync(transcriptsDir)) { //if transcripts directory does not exist, create it
fs.mkdirSync(transcriptsDir, { recursive: true }); fs.mkdirSync(transcriptsDir, { recursive: true });
-4
View File
@@ -5,9 +5,5 @@ module.exports = {
// We are now calling the example function from the example folder // We are now calling the example function from the example folder
mapFunctions.get("example").function("Startup") mapFunctions.get("example").function("Startup")
// mapFunctions.get("extraction-video-to-audio").function({inputVideoPath:"./a.mp4", outputType:"wav"})
// mapFunctions.get("extraction-video-to-audio").function({inputVideoPath:"./b.mp4", outputType:"wav"})
// mapFunctions.get("extraction-video-to-audio").function({inputVideoPath:"./b.mp4", outputType:"flac"})
} }
} }
@@ -1,52 +0,0 @@
// services/pipeline/jobs/transcribeLatest.ts
import path from 'path';
import fs from 'fs';
import assembly from '../../modules/transcription/assembly';
/**
* Finds the most recently modified .wav file in storage/audio/
*/
function getLatestWav(): string {
const audioDir = path.join(process.cwd(), 'storage', 'audio');
const files = fs.readdirSync(audioDir).filter(f => f.toLowerCase().endsWith('.wav'));
if (files.length === 0) throw new Error('⚠️ No .wav file found in storage/audio');
const newest = files
.map(f => ({ f, t: fs.statSync(path.join(audioDir, f)).mtimeMs }))
.sort((a, b) => b.t - a.t)[0].f;
return path.join(audioDir, newest);
}
/**
* Full transcription pipeline according to the defined workflow:
* 1. Audio Upload → AssemblyAI
* 2. Job Creation (transcript_id)
* 3. Polling Status (queued → processing → completed)
* 4. Download Transcript JSON
* 5. Storage: /transcripts/{session_id}.json
*/
async function main() {
const audioPath = getLatestWav();
console.log('1️⃣ Audio Upload → AssemblyAI');
console.log(' Source:', audioPath);
console.log('2️⃣ Job Creation (transcript_id)');
console.log('3️⃣ Polling Status (queued → processing → completed)');
console.log('4️⃣ Download Transcript JSON');
console.log('5️⃣ Storage: /transcripts/{session_id}.json');
// Execute the transcription process via the AssemblyAI module
const result = await assembly.run(audioPath);
console.log('✅ Transcription completed successfully');
console.log('🆔 Transcript ID:', result.id);
console.log('📁 Transcript file saved under: storage/transcripts/');
}
// Entry point
main().catch((err) => {
console.error('❌ Transcription pipeline failed:', err.message || err);
process.exit(1);
});
-7
View File
@@ -1,7 +0,0 @@
spring:
application:
name: v2d-document
app:
external:
apiKey: ${LLM_API_KEY:}
-13
View File
@@ -1,13 +0,0 @@
package com.v2d.document.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "app.external")
public class AppProperties {
private String apiKey;
public String getApiKey() { return apiKey; }
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
}
@@ -1,26 +0,0 @@
package com.v2d.document.controller;
import com.v2d.document.service.ExternalApiService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/generate")
public class GenerateController {
private final ExternalApiService externalApiService;
public GenerateController(ExternalApiService externalApiService) {
this.externalApiService = externalApiService;
}
@PostMapping
public ResponseEntity<String> generate(@RequestBody Map<String,Object> body) {
// Build provider payload from the user's body (transform safely)
String payload = "{\"text\": \"use this text\"}"; // adapt for real usage
String providerResponse = externalApiService.callProvider(payload);
return ResponseEntity.ok(providerResponse);
}
}
-90
View File
@@ -1,90 +0,0 @@
package com.v2d.document.service;
import com.v2d.document.config.AppProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Service
public class ExternalApiService {
private final Logger log = LoggerFactory.getLogger(ExternalApiService.class);
private final AppProperties props;
private final HttpClient http = HttpClient.newHttpClient();
public ExternalApiService(AppProperties props) {
this.props = props;
}
public String callProvider(String jsonPayload) {
String key = props.getApiKey();
if (key == null || key.isBlank()) {
log.warn("External API key is not configured.");
throw new IllegalStateException("External API key missing");
}
try {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/endpoint")) // replace with real endpoint
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
return resp.body();
} catch (Exception e) {
log.error("External API call failed: {}", e.getMessage());
throw new RuntimeException("External API call failed", e);
}
}
}
package com.v2d.document.service;
import com.v2d.document.config.AppProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Service
public class ExternalApiService {
private final Logger log = LoggerFactory.getLogger(ExternalApiService.class);
private final AppProperties props;
private final HttpClient http = HttpClient.newHttpClient();
public ExternalApiService(AppProperties props) {
this.props = props;
}
public String callProvider(String jsonPayload) {
String key = props.getApiKey();
if (key == null || key.isBlank()) {
log.warn("External API key is not configured.");
throw new IllegalStateException("External API key missing");
}
try {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/endpoint")) // replace with real endpoint
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
return resp.body();
} catch (Exception e) {
log.error("External API call failed: {}", e.getMessage());
throw new RuntimeException("External API call failed", e);
}
}
}
-20
View File
@@ -1,20 +0,0 @@
package com.v2d.document.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
public class AppPropertiesTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(AppProperties.class)
.withPropertyValues("app.external.apiKey=TEST_KEY");
@Test
void bindsApiKey() {
contextRunner.run(context -> {
AppProperties props = context.getBean(AppProperties.class);
assertThat(props.getApiKey()).isEqualTo("TEST_KEY");
});
}
}
-14
View File
@@ -1,14 +0,0 @@
import 'dotenv/config';
import assemblyModule from '../../services/modules/transcription-remote/assembly.ts';
// Test: URL passed as argument OR local file ./storage/audio/test.wav
const audioPath = process.argv[2] || './storage/audio/test.wav';
assemblyModule.run(audioPath)
.then(result => {
console.log('✅ Success!');
console.log('Transcript ID:', result.id);
})
.catch(error => {
console.error('❌ Error:', error?.message || error);
});