Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src-tauri/src/file_transcription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ use tauri::{AppHandle, Manager};
/// Sample rate the engine and diarizer both expect (ffmpeg downmixes to this).
pub const TARGET_SAMPLE_RATE: u32 = 16_000;

/// `Command` that doesn't flash a console window on Windows. The GUI app runs
/// with `windows_subsystem = "windows"` (no console of its own), so spawning a
/// child process like ffmpeg would otherwise pop a visible console window for
/// the child's lifetime. No-op on other platforms.
fn no_window_command(program: &str) -> Command {
#[allow(unused_mut)]
let mut cmd = Command::new(program);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
cmd
}

/// Transcribe a file to full details. `diarize`/`speaker_hint` are accepted now
/// but only take effect once `diarization::diarize` is implemented (Phase 2);
/// until then a warning is emitted and speakers stay `None`.
Expand All @@ -30,7 +45,7 @@ pub fn transcribe_file_detailed(
let input_str = input.to_str().context("Input path is not valid UTF-8")?;

// ffmpeg guard — a clear message beats a cryptic spawn failure.
let ffmpeg_ok = Command::new("ffmpeg")
let ffmpeg_ok = no_window_command("ffmpeg")
.arg("-version")
.stdout(Stdio::null())
.stderr(Stdio::null())
Expand Down Expand Up @@ -111,7 +126,7 @@ fn run_engine(
let temp_wav = std::env::temp_dir().join(format!("echo_cli_{}.wav", ts));

println!("[*] Extracting audio via ffmpeg (16kHz mono)...");
let status = Command::new("ffmpeg")
let status = no_window_command("ffmpeg")
.args([
"-i",
input_str,
Expand Down
105 changes: 29 additions & 76 deletions src/components/settings/transcribe/TranscribeFile.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,36 @@
import { open } from "@tauri-apps/plugin-dialog";
import { type FC, useState } from "react";
import { type FC } from "react";
import { useTranslation } from "react-i18next";
import { commands } from "@/bindings";
import { listen } from "@tauri-apps/api/event";
import { ProgressBar } from "@/components/ui/ProgressBar";

type Format = "plain" | "inline" | "srt" | "vtt" | "json" | "karaoke";
import {
useTranscribeStore,
type TranscribeFormat,
} from "@/stores/transcribeStore";

export const TranscribeFile: FC = () => {
const { t } = useTranslation();
const [path, setPath] = useState<string | null>(null);
const [timestamps, setTimestamps] = useState(false);
const [format, setFormat] = useState<Format>("inline");
const [diarize, setDiarize] = useState(false);
const [speakers, setSpeakers] = useState<string>("");
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<{
phase: string;
percent: number | null;
} | null>(null);
const [cancelling, setCancelling] = useState(false);
const [outputPath, setOutputPath] = useState<string | null>(null);
const [savedTo, setSavedTo] = useState<string | null>(null);
const {
path,
timestamps,
format,
diarize,
speakers,
outputPath,
busy,
result,
error,
progress,
cancelling,
savedTo,
setPath,
setTimestamps,
setFormat,
setDiarize,
setSpeakers,
setOutputPath,
run,
cancel,
} = useTranscribeStore();

const pickFile = async () => {
const selected = await open({
Expand All @@ -45,11 +52,7 @@ export const TranscribeFile: FC = () => {
},
],
});
if (typeof selected === "string") {
setPath(selected);
setResult("");
setError(null);
}
if (typeof selected === "string") setPath(selected);
};

const pickOutput = async () => {
Expand All @@ -65,56 +68,6 @@ export const TranscribeFile: FC = () => {
if (typeof target === "string") setOutputPath(target);
};

const run = async () => {
if (!path) return;
setBusy(true);
setError(null);
setResult("");
setSavedTo(null);
setCancelling(false);
setProgress({ phase: "decoding", percent: null });
const effectiveFormat: Format = timestamps ? format : "plain";
const hint = diarize && speakers.trim() ? Number(speakers.trim()) : null;

const unlisten = await listen<{ phase: string; percent: number | null }>(
"transcription-progress",
(e) => setProgress(e.payload),
);
const unlistenDl = await listen<{ percentage: number }>(
"model-download-progress",
(e) =>
setProgress({ phase: "loading_model", percent: e.payload.percentage }),
);
try {
const res = await commands.transcribeFileToString(
path,
null,
null,
diarize,
Number.isFinite(hint) ? hint : null,
effectiveFormat,
);
if (res.status === "ok") {
setResult(res.data);
if (outputPath) {
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(outputPath, res.data);
setSavedTo(outputPath);
}
} else if (!cancelling) setError(res.error);
} finally {
unlisten();
unlistenDl();
setBusy(false);
setProgress(null);
}
};

const cancel = async () => {
setCancelling(true);
await commands.cancelFileTranscription();
};

const save = async () => {
const { save: saveDialog } = await import("@tauri-apps/plugin-dialog");
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
Expand Down Expand Up @@ -168,7 +121,7 @@ export const TranscribeFile: FC = () => {
{timestamps && (
<select
value={format}
onChange={(e) => setFormat(e.target.value as Format)}
onChange={(e) => setFormat(e.target.value as TranscribeFormat)}
className="text-sm bg-transparent border border-slate-700 rounded px-2 py-1"
>
<option value="inline">{t("settings.transcribe.fmtInline")}</option>
Expand Down
128 changes: 128 additions & 0 deletions src/stores/transcribeStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { create } from "zustand";
import { listen } from "@tauri-apps/api/event";
import { commands } from "@/bindings";

export type TranscribeFormat =
| "plain"
| "inline"
| "srt"
| "vtt"
| "json"
| "karaoke";

type Progress = { phase: string; percent: number | null } | null;

interface TranscribeState {
// Inputs (persist across tab switches)
path: string | null;
timestamps: boolean;
format: TranscribeFormat;
diarize: boolean;
speakers: string;
outputPath: string | null;

// Run state (persist so a long transcription stays visible after navigating
// away and back — the component used to hold this locally and lost it on
// unmount, hiding progress and resetting the form mid-run).
busy: boolean;
result: string;
error: string | null;
progress: Progress;
cancelling: boolean;
savedTo: string | null;

setPath: (p: string | null) => void;
setTimestamps: (b: boolean) => void;
setFormat: (f: TranscribeFormat) => void;
setDiarize: (b: boolean) => void;
setSpeakers: (s: string) => void;
setOutputPath: (p: string | null) => void;

run: () => Promise<void>;
cancel: () => Promise<void>;
}

export const useTranscribeStore = create<TranscribeState>((set, get) => ({
path: null,
timestamps: false,
format: "inline",
diarize: false,
speakers: "",
outputPath: null,

busy: false,
result: "",
error: null,
progress: null,
cancelling: false,
savedTo: null,

setPath: (p) => set({ path: p, result: "", error: null }),
setTimestamps: (b) => set({ timestamps: b }),
setFormat: (f) => set({ format: f }),
setDiarize: (b) => set({ diarize: b }),
setSpeakers: (s) => set({ speakers: s }),
setOutputPath: (p) => set({ outputPath: p }),

run: async () => {
const { path, timestamps, format, diarize, speakers, outputPath, busy } =
get();
if (!path || busy) return;

set({
busy: true,
error: null,
result: "",
savedTo: null,
cancelling: false,
progress: { phase: "decoding", percent: null },
});

const effectiveFormat: TranscribeFormat = timestamps ? format : "plain";
const hint = diarize && speakers.trim() ? Number(speakers.trim()) : null;

// Listeners live for the run's lifetime in the store, not the component, so
// progress keeps flowing even while the Transcribe File tab is unmounted.
const unlisten = await listen<{ phase: string; percent: number | null }>(
"transcription-progress",
(e) => set({ progress: e.payload }),
);
const unlistenDl = await listen<{ percentage: number }>(
"model-download-progress",
(e) =>
set({
progress: { phase: "loading_model", percent: e.payload.percentage },
}),
);

try {
const res = await commands.transcribeFileToString(
path,
null,
null,
diarize,
Number.isFinite(hint) ? hint : null,
effectiveFormat,
);
if (res.status === "ok") {
set({ result: res.data });
if (outputPath) {
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(outputPath, res.data);
set({ savedTo: outputPath });
}
} else if (!get().cancelling) {
set({ error: res.error });
}
} finally {
unlisten();
unlistenDl();
set({ busy: false, progress: null });
}
},

cancel: async () => {
set({ cancelling: true });
await commands.cancelFileTranscription();
},
}));
Loading