commit 1272f2c44edfcb364da469d26fc3f0a5f8d2c25f Author: SinuS Von SifriduS Date: Mon Mar 30 10:08:57 2026 +0200 0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2928f85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +.settings +.project +.idea + +Cargo.lock +package-lock.json +/gen/ + +# Generated by Cargo +# will have compiled files and executables +/target/ + +# Generated by Tauri +# will have schema files for capabilities auto-completion +/gen/schemas + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..24d7cc6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..94ef78f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "tauri-video01" +version = "0.1.0" +description = "A Tauri App" +authors = ["you"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# The `_lib` suffix may seem redundant but it is necessary +# to make the lib name unique and wouldn't conflict with the bin name. +# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 +name = "tauri_video01_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +chrono = { version = "^0.4", features = ["clock", "serde"] } +dirs = { version = "^6.0", features = [] } +gstreamer = { version = "^0.25", features = ["serde"] } +serde = { version = "^1.0", features = ["derive"] } +serde_json = { version = "^1.0", features = [] } +tauri = { version = "^2.10", features = ["default"] } +tauri-plugin-opener = { version = "^2.5", features = [] } +tracing = { version = "^0.1", features = ["async-await", "log"] } +tracing-subscriber = { version = "^0.3", features = ["ansi", "env-filter", "chrono", "serde", "json"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..b381dcf --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# Tauri + Vanilla TS + +This template should help get you started developing with Tauri in vanilla HTML, CSS and Typescript. + +## Recommended IDE Setup + +- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/capabilities/default.json b/capabilities/default.json new file mode 100644 index 0000000..4cdbf49 --- /dev/null +++ b/capabilities/default.json @@ -0,0 +1,10 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Capability for the main window", + "windows": ["main"], + "permissions": [ + "core:default", + "opener:default" + ] +} diff --git a/frontend/assets/tauri.svg b/frontend/assets/tauri.svg new file mode 100644 index 0000000..31b62c9 --- /dev/null +++ b/frontend/assets/tauri.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/assets/typescript.svg b/frontend/assets/typescript.svg new file mode 100644 index 0000000..30a5edd --- /dev/null +++ b/frontend/assets/typescript.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/frontend/assets/vite.svg b/frontend/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e7997ad --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,28 @@ + + + + + + +Tauri GST Record + + + +
+

POC 1A - Audio Recording

+ +
+

Audio

+ +
+ + +
+ +
Ready.
+
+
+ + + diff --git a/frontend/main.ts b/frontend/main.ts new file mode 100644 index 0000000..1b1683c --- /dev/null +++ b/frontend/main.ts @@ -0,0 +1,50 @@ +import { invoke } from "@tauri-apps/api/core"; + +const startAudioBtn = document.querySelector("#start-audio-btn"); +const stopAudioBtn = document.querySelector("#stop-audio-btn"); +const audioStatus = document.querySelector("#audio-status"); + +if (startAudioBtn === null || stopAudioBtn === null || audioStatus === null) { + throw new Error("missing UI elements"); +} + +function setAudioStatus(message: string): void { + if (audioStatus) { + audioStatus.textContent = message; + } +} + +function setAudioButtons(isRecording: boolean): void { + if (startAudioBtn) { + startAudioBtn.disabled = isRecording; + } + if (stopAudioBtn) { + stopAudioBtn.disabled = !isRecording; + } +} + +startAudioBtn.addEventListener("click", async () => { + startAudioBtn.disabled = true; + + try { + const path = await invoke("start_audio_recording"); + setAudioStatus(`Audio recording started.\nOutput: ${path}`); + setAudioButtons(true); + } catch (error) { + setAudioStatus(`Start audio failed.\n${String(error)}`); + setAudioButtons(false); + } +}); + +stopAudioBtn.addEventListener("click", async () => { + stopAudioBtn.disabled = true; + + try { + const path = await invoke("stop_audio_recording"); + setAudioStatus(`Audio recording stopped.\nSaved file: ${path}`); + setAudioButtons(false); + } catch (error) { + setAudioStatus(`Stop audio failed.\n${String(error)}`); + setAudioButtons(true); + } +}); diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..e02ec3a --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,50 @@ +:root { + color-scheme: dark; + font-family: Arial, Helvetica, sans-serif; +} + +body { + margin: 0; + background: #111827; + color: #f9fafb; +} + +.container { + max-width: 900px; + margin: 0 auto; + padding: 24px; +} + +.card { + background: #1f2937; + border: 1px solid #374151; + border-radius: 12px; + padding: 20px; +} + +.actions { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +button { + border: 0; + border-radius: 8px; + padding: 10px 16px; + cursor: pointer; + font-size: 16px; +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +pre { + background: #0b1220; + border: 1px solid #374151; + border-radius: 8px; + padding: 12px; + white-space: pre-wrap; +} \ No newline at end of file diff --git a/icons/128x128.png b/icons/128x128.png new file mode 100644 index 0000000..6be5e50 Binary files /dev/null and b/icons/128x128.png differ diff --git a/icons/128x128@2x.png b/icons/128x128@2x.png new file mode 100644 index 0000000..e81bece Binary files /dev/null and b/icons/128x128@2x.png differ diff --git a/icons/32x32.png b/icons/32x32.png new file mode 100644 index 0000000..a437dd5 Binary files /dev/null and b/icons/32x32.png differ diff --git a/icons/Square107x107Logo.png b/icons/Square107x107Logo.png new file mode 100644 index 0000000..0ca4f27 Binary files /dev/null and b/icons/Square107x107Logo.png differ diff --git a/icons/Square142x142Logo.png b/icons/Square142x142Logo.png new file mode 100644 index 0000000..b81f820 Binary files /dev/null and b/icons/Square142x142Logo.png differ diff --git a/icons/Square150x150Logo.png b/icons/Square150x150Logo.png new file mode 100644 index 0000000..624c7bf Binary files /dev/null and b/icons/Square150x150Logo.png differ diff --git a/icons/Square284x284Logo.png b/icons/Square284x284Logo.png new file mode 100644 index 0000000..c021d2b Binary files /dev/null and b/icons/Square284x284Logo.png differ diff --git a/icons/Square30x30Logo.png b/icons/Square30x30Logo.png new file mode 100644 index 0000000..6219700 Binary files /dev/null and b/icons/Square30x30Logo.png differ diff --git a/icons/Square310x310Logo.png b/icons/Square310x310Logo.png new file mode 100644 index 0000000..f9bc048 Binary files /dev/null and b/icons/Square310x310Logo.png differ diff --git a/icons/Square44x44Logo.png b/icons/Square44x44Logo.png new file mode 100644 index 0000000..d5fbfb2 Binary files /dev/null and b/icons/Square44x44Logo.png differ diff --git a/icons/Square71x71Logo.png b/icons/Square71x71Logo.png new file mode 100644 index 0000000..63440d7 Binary files /dev/null and b/icons/Square71x71Logo.png differ diff --git a/icons/Square89x89Logo.png b/icons/Square89x89Logo.png new file mode 100644 index 0000000..f3f705a Binary files /dev/null and b/icons/Square89x89Logo.png differ diff --git a/icons/StoreLogo.png b/icons/StoreLogo.png new file mode 100644 index 0000000..4556388 Binary files /dev/null and b/icons/StoreLogo.png differ diff --git a/icons/icon.icns b/icons/icon.icns new file mode 100644 index 0000000..12a5bce Binary files /dev/null and b/icons/icon.icns differ diff --git a/icons/icon.ico b/icons/icon.ico new file mode 100644 index 0000000..06c23c8 Binary files /dev/null and b/icons/icon.ico differ diff --git a/icons/icon.png b/icons/icon.png new file mode 100644 index 0000000..e1cd261 Binary files /dev/null and b/icons/icon.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..69b16b3 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "tauri-video01", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.10", + "@tauri-apps/plugin-opener": "^2.5" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.10", + "@types/node": "^25.3", + "sass-embedded": "^1.97", + "typescript": "^5.9", + "vite": "npm:rolldown-vite@^7.3" + } +} diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..de405a1 --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,29 @@ +// file: src/app_state.rs + +pub struct AudioRecorderState { + pub is_recording: bool, + pub output_path: Option, + pub pipeline: Option, +} + +impl AudioRecorderState { + pub fn new() -> Self { + Self { + is_recording: false, + output_path: None, + pipeline: None, + } + } +} + +pub struct AppState { + pub audio: std::sync::Mutex, +} + +impl AppState { + pub fn new() -> Self { + Self { + audio: std::sync::Mutex::new(AudioRecorderState::new()), + } + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..b22ed54 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,26 @@ +// file: src/commands.rs + +use crate::app_state::AppState; +use crate::media_audio; + +#[tauri::command] +pub async fn start_audio_recording( + state: tauri::State<'_, AppState>, +) -> Result { + let result = media_audio::start_audio_recording(&state); + match result { + Ok(path) => Ok(path), + Err(error) => Err(error.to_user_message()), + } +} + +#[tauri::command] +pub async fn stop_audio_recording( + state: tauri::State<'_, AppState>, +) -> Result { + let result = media_audio::stop_audio_recording(&state); + match result { + Ok(path) => Ok(path), + Err(error) => Err(error.to_user_message()), + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9dbe3db --- /dev/null +++ b/src/error.rs @@ -0,0 +1,22 @@ +// file: src/error.rs + +#[derive(Debug)] +pub enum AppError { + GstInit(String), + Gst(String), + Io(String), + State(String), + InvalidInput(String), +} + +impl AppError { + pub fn to_user_message(&self) -> String { + match self { + AppError::GstInit(message) => format!("gstreamer init error: {message}"), + AppError::Gst(message) => format!("gstreamer error: {message}"), + AppError::Io(message) => format!("io error: {message}"), + AppError::State(message) => format!("state error: {message}"), + AppError::InvalidInput(message) => format!("invalid input: {message}"), + } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..1debd3f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,43 @@ +// file: src/lib.rs + +mod app_state; +mod commands; +mod error; +mod media_audio; + +fn init_tracing() { + let subscriber_result = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .try_init(); + + if let Err(error) = subscriber_result { + eprintln!("failed to init tracing: {error}"); + } +} + +fn init_gstreamer() { + let init_result = gstreamer::init(); + if let Err(error) = init_result { + panic!("failed to init gstreamer: {error}"); + } +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + init_tracing(); + init_gstreamer(); + let builder = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .manage(app_state::AppState::new()) + .invoke_handler(tauri::generate_handler![ + commands::start_audio_recording, + commands::stop_audio_recording + ]); + let run_result = builder.run(tauri::generate_context!()); + if let Err(error) = run_result { + tracing::error!(%error, "tauri run failed"); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..96baa33 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,8 @@ +// file: src/main.rs + +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri_video01_lib::run() +} diff --git a/src/media_audio.rs b/src/media_audio.rs new file mode 100644 index 0000000..d70a589 --- /dev/null +++ b/src/media_audio.rs @@ -0,0 +1,165 @@ +// file: src/media_audio.rs + +use crate::app_state::AppState; +use crate::error::AppError; +use gstreamer as gst; +use gst::prelude::*; + +fn build_output_dir() -> Result { + let app_data_dir = dirs::data_local_dir(); + + let Some(mut base_dir) = app_data_dir else { + return Err(AppError::Io("unable to resolve local data directory".to_string())); + }; + + base_dir.push("tauri-gst-record"); + base_dir.push("records"); + + let create_dir_result = std::fs::create_dir_all(&base_dir); + if let Err(error) = create_dir_result { + return Err(AppError::Io(format!( + "unable to create output directory '{}': {error}", + base_dir.display() + ))); + } + + Ok(base_dir) +} + +fn build_output_path() -> Result { + let mut dir = build_output_dir()?; + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); + dir.push(format!("audio-{timestamp}.wav")); + Ok(dir) +} + +fn build_pipeline(output_path: &std::path::Path) -> Result { + let location = output_path.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\""); + let pipeline_str = format!( + "autoaudiosrc ! audioconvert ! audioresample ! wavenc ! filesink location=\"{location}\"" + ); + + tracing::info!(pipeline = %pipeline_str, "building audio pipeline"); + + let element_result = gst::parse::launch(&pipeline_str); + let element = match element_result { + Ok(value) => value, + Err(error) => { + return Err(AppError::Gst(format!( + "unable to parse pipeline: {error}" + ))); + } + }; + + let downcast_result = element.downcast::(); + match downcast_result { + Ok(pipeline) => Ok(pipeline), + Err(_) => Err(AppError::Gst( + "parsed element is not a pipeline".to_string(), + )), + } +} + +pub fn start_audio_recording(state: &tauri::State<'_, AppState>) -> Result { + let mut guard = match state.audio.lock() { + Ok(value) => value, + Err(_) => { + return Err(AppError::State( + "audio state lock poisoned".to_string(), + )); + } + }; + + if guard.is_recording { + return Err(AppError::State( + "audio recording is already running".to_string(), + )); + } + + let output_path = build_output_path()?; + let pipeline = build_pipeline(&output_path)?; + + let set_state_result = pipeline.set_state(gst::State::Playing); + match set_state_result { + Ok(_) => {} + Err(error) => { + return Err(AppError::Gst(format!( + "unable to set pipeline to playing: {error:?}" + ))); + } + } + + tracing::info!(path = %output_path.display(), "audio recording started"); + + guard.is_recording = true; + guard.output_path = Some(output_path.clone()); + guard.pipeline = Some(pipeline); + + Ok(output_path.display().to_string()) +} + +pub fn stop_audio_recording(state: &tauri::State<'_, AppState>) -> Result { + let mut guard = match state.audio.lock() { + Ok(value) => value, + Err(_) => { + return Err(AppError::State( + "audio state lock poisoned".to_string(), + )); + } + }; + + if !guard.is_recording { + return Err(AppError::State( + "audio recording is not running".to_string(), + )); + } + + let pipeline = match guard.pipeline.take() { + Some(value) => value, + None => { + return Err(AppError::State( + "missing pipeline while recording flag is set".to_string(), + )); + } + }; + + let output_path = match guard.output_path.clone() { + Some(value) => value, + None => { + return Err(AppError::State( + "missing output path while recording flag is set".to_string(), + )); + } + }; + + let send_event_result = pipeline.send_event(gst::event::Eos::new()); + if !send_event_result { + tracing::warn!("failed to send EOS event to pipeline"); + } + + let bus = pipeline.bus(); + if let Some(bus_ref) = bus { + let _ = bus_ref.timed_pop_filtered( + gst::ClockTime::from_seconds(5), + &[gst::MessageType::Eos, gst::MessageType::Error], + ); + } + + let set_state_result = pipeline.set_state(gst::State::Null); + match set_state_result { + Ok(_) => {} + Err(error) => { + return Err(AppError::Gst(format!( + "unable to set pipeline to null: {error:?}" + ))); + } + } + + tracing::info!(path = %output_path.display(), "audio recording stopped"); + + guard.is_recording = false; + guard.output_path = None; + guard.pipeline = None; + + Ok(output_path.display().to_string()) +} diff --git a/tauri.conf.json b/tauri.conf.json new file mode 100644 index 0000000..58b26e8 --- /dev/null +++ b/tauri.conf.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "tauri-video01", + "version": "0.1.0", + "identifier": "com.sinus.tauri-video01", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "./dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "tauri-video01", + "width": 800, + "height": 600 + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f1e1928 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ES2020", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": "./frontend" + }, + "include": [ + "frontend" + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..79531e8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, normalizePath } from "vite"; +import { NodePackageImporter } from "sass-embedded"; +import { resolve } from 'path'; + +const host = process.env.TAURI_DEV_HOST; + +// https://vite.dev/config/ +export default defineConfig(async () => ({ + + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + root: 'frontend', // Set this to your frontend directory + publicDir: 'public', + build: { + outDir: './dist', // Output directory for the build + emptyOutDir: true, + rollupOptions: { + input: { + "main": normalizePath(resolve(__dirname, 'frontend/index.html')) + }, + output: { + entryFileNames: 'js/[name]-[hash].js', + chunkFileNames: 'js/chunks/[name]-[hash].js', + assetFileNames: (assetInfo) => { + const originalName = assetInfo.name ?? ''; + const ext = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase(); + if (ext === 'js') { + return 'js/[name]-[hash][extname]'; + } + // CSS + if (ext === 'css') { + return 'css/[name]-[hash][extname]'; + } + if (['eot', 'otf', 'ttf', 'woff', 'woff2'].includes(ext)) { + return 'fonts/[name]-[hash][extname]'; + } + if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'].includes(ext)) { + return 'imgs/[name][extname]'; + } + if (['mp4', 'webm'].includes(ext)) { + return 'videos/[name][extname]'; + } + return 'otherassets/[name][extname]'; + }, + }, + }, + minify: true, + sourcemap: false, + cssCodeSplit: true + }, + css: { + preprocessorOptions: { + scss: { + api: 'modern-compiler', + importers: [new NodePackageImporter()], + } + } + }, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: 1420, + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: 1421, + } + : undefined, + watch: { + ignored: ["**/src/**"], + }, + }, +}));