Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd7754dae | ||
|
|
9ae4ca1b2b |
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
target/
|
||||
src-tauri/target/
|
||||
*.tsbuildinfo
|
||||
dist-packages/
|
||||
app-icon.png
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>mp3Transriber</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "mp3transriber",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "mp3transriber"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability set for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 299 B |
|
After Width: | Height: | Size: 665 B |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 239 B |
|
After Width: | Height: | Size: 328 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 852 B |
|
After Width: | Height: | Size: 121 B |
|
After Width: | Height: | Size: 172 B |
|
After Width: | Height: | Size: 200 B |
|
After Width: | Height: | Size: 130 B |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 177 B |
|
After Width: | Height: | Size: 374 B |
|
After Width: | Height: | Size: 281 B |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 240 B |
|
After Width: | Height: | Size: 278 B |
|
After Width: | Height: | Size: 285 B |
|
After Width: | Height: | Size: 515 B |
|
After Width: | Height: | Size: 488 B |
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 902 B |
|
After Width: | Height: | Size: 717 B |
|
After Width: | Height: | Size: 567 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 905 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 91 B |
|
After Width: | Height: | Size: 116 B |
|
After Width: | Height: | Size: 116 B |
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 104 B |
|
After Width: | Height: | Size: 143 B |
|
After Width: | Height: | Size: 143 B |
|
After Width: | Height: | Size: 193 B |
|
After Width: | Height: | Size: 116 B |
|
After Width: | Height: | Size: 185 B |
|
After Width: | Height: | Size: 185 B |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 263 B |
|
After Width: | Height: | Size: 419 B |
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 352 B |
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,164 @@
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::models::{Recording, RecordingMetadata, TranscriptRecord};
|
||||
|
||||
pub struct Database {
|
||||
connection: Connection,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn open(path: &Path) -> Result<Self, String> {
|
||||
let connection = Connection::open(path).map_err(|err| err.to_string())?;
|
||||
let database = Self { connection };
|
||||
database.migrate()?;
|
||||
Ok(database)
|
||||
}
|
||||
|
||||
fn migrate(&self) -> Result<(), String> {
|
||||
self.connection
|
||||
.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id TEXT PRIMARY KEY,
|
||||
file_name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
imported_at TEXT NOT NULL,
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
date TEXT,
|
||||
duration_seconds INTEGER,
|
||||
bitrate_kbps INTEGER,
|
||||
transcript_text TEXT,
|
||||
transcript_source TEXT,
|
||||
transcript_status TEXT
|
||||
);
|
||||
",
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub fn list_recordings(&self) -> Result<Vec<Recording>, String> {
|
||||
let mut statement = self
|
||||
.connection
|
||||
.prepare(
|
||||
"
|
||||
SELECT
|
||||
id,
|
||||
file_name,
|
||||
file_path,
|
||||
imported_at,
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
date,
|
||||
duration_seconds,
|
||||
bitrate_kbps,
|
||||
transcript_text,
|
||||
transcript_source,
|
||||
transcript_status
|
||||
FROM recordings
|
||||
ORDER BY imported_at DESC
|
||||
",
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
let transcript_text: Option<String> = row.get(10)?;
|
||||
let transcript_source: Option<String> = row.get(11)?;
|
||||
let transcript_status: Option<String> = row.get(12)?;
|
||||
|
||||
Ok(Recording {
|
||||
id: row.get(0)?,
|
||||
file_name: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
imported_at: row.get(3)?,
|
||||
metadata: RecordingMetadata {
|
||||
title: row.get(4)?,
|
||||
artist: row.get(5)?,
|
||||
album: row.get(6)?,
|
||||
date: row.get(7)?,
|
||||
duration_seconds: row.get(8)?,
|
||||
bitrate_kbps: row.get(9)?,
|
||||
},
|
||||
transcript: transcript_text.map(|text| TranscriptRecord {
|
||||
text,
|
||||
source: transcript_source.unwrap_or_else(|| "local".to_string()),
|
||||
status: transcript_status.unwrap_or_else(|| "ready".to_string()),
|
||||
}),
|
||||
})
|
||||
})
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub fn create_recording(
|
||||
&self,
|
||||
id: &str,
|
||||
file_name: &str,
|
||||
file_path: &str,
|
||||
imported_at: &str,
|
||||
metadata: &RecordingMetadata,
|
||||
) -> Result<(), String> {
|
||||
self.connection
|
||||
.execute(
|
||||
"
|
||||
INSERT INTO recordings (
|
||||
id,
|
||||
file_name,
|
||||
file_path,
|
||||
imported_at,
|
||||
title,
|
||||
artist,
|
||||
album,
|
||||
date,
|
||||
duration_seconds,
|
||||
bitrate_kbps,
|
||||
transcript_status
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||
ON CONFLICT(file_path) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
artist = excluded.artist,
|
||||
album = excluded.album,
|
||||
date = excluded.date,
|
||||
duration_seconds = excluded.duration_seconds,
|
||||
bitrate_kbps = excluded.bitrate_kbps
|
||||
",
|
||||
params![
|
||||
id,
|
||||
file_name,
|
||||
file_path,
|
||||
imported_at,
|
||||
metadata.title,
|
||||
metadata.artist,
|
||||
metadata.album,
|
||||
metadata.date,
|
||||
metadata.duration_seconds,
|
||||
metadata.bitrate_kbps,
|
||||
"pending"
|
||||
],
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_transcript(&self, recording_id: &str, text: &str, source: &str) -> Result<(), String> {
|
||||
self.connection
|
||||
.execute(
|
||||
"
|
||||
UPDATE recordings
|
||||
SET transcript_text = ?1, transcript_source = ?2, transcript_status = ?3
|
||||
WHERE id = ?4
|
||||
",
|
||||
params![text, source, "ready", recording_id],
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod db;
|
||||
mod metadata;
|
||||
mod models;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use db::Database;
|
||||
use models::Recording;
|
||||
use tauri::Manager;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct AppState {
|
||||
db: Mutex<Database>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_recordings(state: tauri::State<'_, AppState>) -> Result<Vec<Recording>, String> {
|
||||
state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock database state".to_string())?
|
||||
.list_recordings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn import_mp3(file_path: String, state: tauri::State<'_, AppState>) -> Result<Recording, String> {
|
||||
let path = PathBuf::from(&file_path);
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| "Invalid file path".to_string())?
|
||||
.to_string();
|
||||
|
||||
let metadata = metadata::read_metadata(&file_path);
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let imported_at = format!("{:?}", std::time::SystemTime::now());
|
||||
|
||||
{
|
||||
let database = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock database state".to_string())?;
|
||||
database.create_recording(&id, &file_name, &file_path, &imported_at, &metadata)?;
|
||||
}
|
||||
|
||||
Ok(Recording {
|
||||
id,
|
||||
file_name,
|
||||
file_path,
|
||||
imported_at,
|
||||
metadata,
|
||||
transcript: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn transcribe_recording(
|
||||
recording_id: String,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<Recording, String> {
|
||||
let transcript_text = "Local transcription pipeline not wired yet. This is the first scaffolded backend placeholder.".to_string();
|
||||
|
||||
{
|
||||
let database = state
|
||||
.db
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock database state".to_string())?;
|
||||
database.save_transcript(&recording_id, &transcript_text, "local")?;
|
||||
let recordings = database.list_recordings()?;
|
||||
if let Some(recording) = recordings.into_iter().find(|item| item.id == recording_id) {
|
||||
return Ok(recording);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Recording not found".to_string())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
let app_data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|err| -> Box<dyn std::error::Error> { Box::new(err) })?;
|
||||
|
||||
std::fs::create_dir_all(&app_data_dir)?;
|
||||
let db_path = app_data_dir.join("mp3transriber.sqlite3");
|
||||
let database = Database::open(&db_path).map_err(std::io::Error::other)?;
|
||||
|
||||
app.manage(AppState {
|
||||
db: Mutex::new(database),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_recordings,
|
||||
import_mp3,
|
||||
transcribe_recording
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::models::RecordingMetadata;
|
||||
|
||||
pub fn read_metadata(file_path: &str) -> RecordingMetadata {
|
||||
let fallback_title = Path::new(file_path)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
let output = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
file_path,
|
||||
])
|
||||
.output();
|
||||
|
||||
let Ok(output) = output else {
|
||||
return RecordingMetadata {
|
||||
title: fallback_title,
|
||||
artist: None,
|
||||
album: None,
|
||||
date: None,
|
||||
duration_seconds: None,
|
||||
bitrate_kbps: None,
|
||||
};
|
||||
};
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_slice(&output.stdout) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return RecordingMetadata {
|
||||
title: fallback_title,
|
||||
artist: None,
|
||||
album: None,
|
||||
date: None,
|
||||
duration_seconds: None,
|
||||
bitrate_kbps: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tags = json
|
||||
.get("format")
|
||||
.and_then(|format| format.get("tags"))
|
||||
.and_then(|tags| tags.as_object());
|
||||
|
||||
let duration_seconds = json
|
||||
.get("format")
|
||||
.and_then(|format| format.get("duration"))
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(|value| value.parse::<f64>().ok())
|
||||
.map(|value| value.round() as u64);
|
||||
|
||||
let bitrate_kbps = json
|
||||
.get("format")
|
||||
.and_then(|format| format.get("bit_rate"))
|
||||
.and_then(|value| value.as_str())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.map(|value| (value / 1000) as u32);
|
||||
|
||||
RecordingMetadata {
|
||||
title: tags
|
||||
.and_then(|items| items.get("title"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string())
|
||||
.or(fallback_title),
|
||||
artist: tags
|
||||
.and_then(|items| items.get("artist"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string()),
|
||||
album: tags
|
||||
.and_then(|items| items.get("album"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string()),
|
||||
date: tags
|
||||
.and_then(|items| items.get("date"))
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string()),
|
||||
duration_seconds,
|
||||
bitrate_kbps,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RecordingMetadata {
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub duration_seconds: Option<u64>,
|
||||
pub bitrate_kbps: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TranscriptRecord {
|
||||
pub text: String,
|
||||
pub source: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Recording {
|
||||
pub id: String,
|
||||
pub file_name: String,
|
||||
pub file_path: String,
|
||||
pub imported_at: String,
|
||||
pub metadata: RecordingMetadata,
|
||||
pub transcript: Option<TranscriptRecord>,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "mp3Transriber",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.ben.mp3transriber",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:5173"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "mp3Transriber",
|
||||
"width": 1320,
|
||||
"height": 860,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/64x64.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { importMp3, listRecordings, transcribeRecording } from "./lib/desktop";
|
||||
import type { Recording } from "./types";
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (seconds === undefined) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = seconds % 60;
|
||||
return `${minutes}:${remaining.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [recordings, setRecordings] = useState<Recording[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [manualPath, setManualPath] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRecordings();
|
||||
}, []);
|
||||
|
||||
const selectedRecording = useMemo(
|
||||
() => recordings.find((recording) => recording.id === selectedId) ?? recordings[0] ?? null,
|
||||
[recordings, selectedId]
|
||||
);
|
||||
|
||||
async function refreshRecordings() {
|
||||
try {
|
||||
setError(null);
|
||||
const data = await listRecordings();
|
||||
setRecordings(data);
|
||||
if (!selectedId && data.length > 0) {
|
||||
setSelectedId(data[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load recordings.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!manualPath.trim()) {
|
||||
setError("Enter an MP3 file path to import.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBusy("import");
|
||||
setError(null);
|
||||
const recording = await importMp3(manualPath.trim());
|
||||
setRecordings((current) => [recording, ...current]);
|
||||
setSelectedId(recording.id);
|
||||
setManualPath("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Import failed.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTranscribe() {
|
||||
if (!selectedRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setBusy("transcribe");
|
||||
setError(null);
|
||||
const updated = await transcribeRecording(selectedRecording.id);
|
||||
setRecordings((current) =>
|
||||
current.map((recording) => (recording.id === updated.id ? updated : recording))
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Transcription failed.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first desktop app</p>
|
||||
<h1>mp3Transriber</h1>
|
||||
<p className="hero-copy">
|
||||
Manage recorder MP3 files, inspect metadata, and save transcripts locally by default.
|
||||
</p>
|
||||
</div>
|
||||
<div className="privacy-card">
|
||||
<strong>Privacy default</strong>
|
||||
<p>Audio and transcript text stay on-device unless a future cloud provider is explicitly enabled.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="import-bar">
|
||||
<input
|
||||
aria-label="MP3 file path"
|
||||
placeholder="/path/to/recording.mp3"
|
||||
value={manualPath}
|
||||
onChange={(event) => setManualPath(event.target.value)}
|
||||
/>
|
||||
<button onClick={handleImport} disabled={busy === "import"}>
|
||||
{busy === "import" ? "Importing..." : "Import MP3"}
|
||||
</button>
|
||||
<button className="secondary" onClick={() => void refreshRecordings()}>
|
||||
Refresh
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{error ? <p className="error-banner">{error}</p> : null}
|
||||
|
||||
<main className="content-grid">
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Recordings</h2>
|
||||
<span>{recordings.length}</span>
|
||||
</div>
|
||||
<div className="recordings-list">
|
||||
{recordings.map((recording) => (
|
||||
<button
|
||||
key={recording.id}
|
||||
className={recording.id === selectedRecording?.id ? "recording-item active" : "recording-item"}
|
||||
onClick={() => setSelectedId(recording.id)}
|
||||
>
|
||||
<strong>{recording.fileName}</strong>
|
||||
<span>{new Date(recording.importedAt).toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
{recordings.length === 0 ? <p className="empty-state">No recordings imported yet.</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>Metadata</h2>
|
||||
<button onClick={handleTranscribe} disabled={!selectedRecording || busy === "transcribe"}>
|
||||
{busy === "transcribe" ? "Transcribing..." : "Transcribe"}
|
||||
</button>
|
||||
</div>
|
||||
{selectedRecording ? (
|
||||
<dl className="metadata-grid">
|
||||
<div>
|
||||
<dt>Path</dt>
|
||||
<dd>{selectedRecording.filePath}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Title</dt>
|
||||
<dd>{selectedRecording.metadata.title ?? "Unknown"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Artist</dt>
|
||||
<dd>{selectedRecording.metadata.artist ?? "Unknown"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Album</dt>
|
||||
<dd>{selectedRecording.metadata.album ?? "Unknown"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Date</dt>
|
||||
<dd>{selectedRecording.metadata.date ?? "Unknown"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Duration</dt>
|
||||
<dd>{formatDuration(selectedRecording.metadata.durationSeconds)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bitrate</dt>
|
||||
<dd>
|
||||
{selectedRecording.metadata.bitrateKbps !== undefined
|
||||
? `${selectedRecording.metadata.bitrateKbps} kbps`
|
||||
: "Unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="empty-state">Select a recording to inspect its metadata.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel transcript-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Transcript</h2>
|
||||
<span>{selectedRecording?.transcript?.source ?? "none"}</span>
|
||||
</div>
|
||||
{selectedRecording?.transcript ? (
|
||||
<>
|
||||
<p className="status-pill">{selectedRecording.transcript.status}</p>
|
||||
<pre>{selectedRecording.transcript.text}</pre>
|
||||
</>
|
||||
) : (
|
||||
<p className="empty-state">No transcript saved for this recording yet.</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Recording } from "../types";
|
||||
|
||||
const mockRecordings: Recording[] = [
|
||||
{
|
||||
id: "demo-1",
|
||||
fileName: "meeting-note.mp3",
|
||||
filePath: "/recordings/meeting-note.mp3",
|
||||
importedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
title: "Meeting Note",
|
||||
artist: "Pocket Recorder",
|
||||
album: "Field Notes",
|
||||
durationSeconds: 74,
|
||||
bitrateKbps: 192
|
||||
},
|
||||
transcript: {
|
||||
text: "This is sample placeholder transcript text for the initial scaffold.",
|
||||
source: "local",
|
||||
status: "ready"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function isTauriRuntime(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
export async function listRecordings(): Promise<Recording[]> {
|
||||
if (!isTauriRuntime()) {
|
||||
return mockRecordings;
|
||||
}
|
||||
|
||||
return invoke<Recording[]>("list_recordings");
|
||||
}
|
||||
|
||||
export async function importMp3(filePath: string): Promise<Recording> {
|
||||
if (!isTauriRuntime()) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
fileName: filePath.split("/").pop() ?? "recording.mp3",
|
||||
filePath,
|
||||
importedAt: new Date().toISOString(),
|
||||
metadata: {},
|
||||
transcript: {
|
||||
text: "Local import mock created in browser mode.",
|
||||
source: "local",
|
||||
status: "pending"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return invoke<Recording>("import_mp3", { filePath });
|
||||
}
|
||||
|
||||
export async function transcribeRecording(recordingId: string): Promise<Recording> {
|
||||
if (!isTauriRuntime()) {
|
||||
const recording = mockRecordings.find((item) => item.id === recordingId);
|
||||
if (!recording) {
|
||||
throw new Error("Recording not found");
|
||||
}
|
||||
|
||||
return {
|
||||
...recording,
|
||||
transcript: {
|
||||
text: "Mock local transcription completed in browser mode.",
|
||||
source: "local",
|
||||
status: "ready"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return invoke<Recording>("transcribe_recording", { recordingId });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,226 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #93c5fd;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
max-width: 48rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.privacy-card,
|
||||
.panel {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.15);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 50px rgba(2, 6, 23, 0.25);
|
||||
}
|
||||
|
||||
.privacy-card {
|
||||
max-width: 22rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.privacy-card p {
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.import-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(127, 29, 29, 0.65);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.85fr) minmax(320px, 1fr) minmax(320px, 1.1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.recordings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recording-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
gap: 0.25rem;
|
||||
text-align: left;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.recording-item.active {
|
||||
outline: 1px solid #60a5fa;
|
||||
background: #172554;
|
||||
}
|
||||
|
||||
.recording-item span,
|
||||
.empty-state,
|
||||
dd {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #93c5fd;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.transcript-panel pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
line-height: 1.6;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: #1d4ed8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero,
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.privacy-card {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.import-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type TranscriptSource = "local" | "cloud";
|
||||
|
||||
export interface RecordingMetadata {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
date?: string;
|
||||
durationSeconds?: number;
|
||||
bitrateKbps?: number;
|
||||
}
|
||||
|
||||
export interface TranscriptRecord {
|
||||
text: string;
|
||||
source: TranscriptSource;
|
||||
status: "ready" | "pending";
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
id: string;
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
importedAt: string;
|
||||
metadata: RecordingMetadata;
|
||||
transcript?: TranscriptRecord;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
create an app that allows a user to manage recordings from a pocket recorder device.
|
||||
Allow user to see the meta data in the mp3's .
|
||||
|
||||
add a feture that can transcribe the mp3 to text and also save the transcibed text in the app.
|
||||
use an sqllite3 database.
|
||||
|
||||
the app must be able to run on linux or a mac computer and easy to start when downloaded / aquired.
|
||||
|
||||
discuss a dev stack for this project
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
clearScreen: false
|
||||
});
|
||||