feat: add global recording shortcut
This commit is contained in:
1 parent
1f6ea8e5db
commit
2aaad73652
3 files changed
+268
-196
No files matched your search
@@ -4,6 +4,7 @@ pub mod error;
|
|||||||
pub mod injection;
|
pub mod injection;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod shortcuts;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod tray;
|
pub mod tray;
|
||||||
pub mod ui_commands;
|
pub mod ui_commands;
|
||||||
@@ -14,8 +15,10 @@ use app_state::ManagedAppState;
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(shortcuts::plugin())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
tray::setup(app)?;
|
tray::setup(app)?;
|
||||||
|
shortcuts::setup(app)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.manage(ManagedAppState::default())
|
.manage(ManagedAppState::default())
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use tauri::{App, Emitter, Runtime};
|
||||||
|
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||||
|
|
||||||
|
pub const TOGGLE_RECORDING_EVENT: &str = "transcript://toggle-recording";
|
||||||
|
|
||||||
|
pub fn plugin<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||||
|
tauri_plugin_global_shortcut::Builder::new()
|
||||||
|
.with_handler(|app, _shortcut, event| {
|
||||||
|
if event.state() == ShortcutState::Pressed {
|
||||||
|
let _ = app.emit(TOGGLE_RECORDING_EVENT, ());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup(app: &App) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space);
|
||||||
|
app.global_shortcut().register(shortcut)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+245
-196
@@ -1,251 +1,300 @@
|
|||||||
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
export type RecordingStatus = 'idle' | 'recording';
|
export type RecordingStatus = "idle" | "recording";
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
transcription: {
|
transcription: {
|
||||||
mode: 'localFirst' | 'apiOnly';
|
mode: "localFirst" | "apiOnly";
|
||||||
modelId: string;
|
modelId: string;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
openaiCompatible: {
|
openaiCompatible: {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
apiKeyConfigured: boolean;
|
apiKeyConfigured: boolean;
|
||||||
model: string;
|
model: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
insertion: {
|
insertion: {
|
||||||
method: 'clipboardPaste' | 'keyTyping' | 'manualCopy';
|
method: "clipboardPaste" | "keyTyping" | "manualCopy";
|
||||||
restoreClipboard: boolean;
|
restoreClipboard: boolean;
|
||||||
waylandManualFallback: boolean;
|
waylandManualFallback: boolean;
|
||||||
};
|
};
|
||||||
storage: {
|
storage: {
|
||||||
keepAudio: boolean;
|
keepAudio: boolean;
|
||||||
audioFormat: 'wav' | 'ogg' | 'mp3';
|
audioFormat: "wav" | "ogg" | "mp3";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelWithStatus {
|
export interface ModelWithStatus {
|
||||||
model: {
|
model: {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
repository: string;
|
repository: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
quantization: string;
|
quantization: string;
|
||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
languages: string[];
|
languages: string[];
|
||||||
recommendedForSimplePc: boolean;
|
recommendedForSimplePc: boolean;
|
||||||
};
|
};
|
||||||
status: 'notInstalled' | 'ready' | { error: string };
|
status: "notInstalled" | "ready" | { error: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptDraft {
|
export interface TranscriptDraft {
|
||||||
text: string;
|
text: string;
|
||||||
lastMessage: string | null;
|
lastMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSnapshot {
|
export interface AppSnapshot {
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
recordingStatus: RecordingStatus;
|
recordingStatus: RecordingStatus;
|
||||||
transcript: TranscriptDraft;
|
transcript: TranscriptDraft;
|
||||||
models: ModelWithStatus[];
|
models: ModelWithStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NativeClient {
|
export interface NativeClient {
|
||||||
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>;
|
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackSnapshot: AppSnapshot = {
|
const fallbackSnapshot: AppSnapshot = {
|
||||||
recordingStatus: 'idle',
|
recordingStatus: "idle",
|
||||||
transcript: {
|
transcript: {
|
||||||
text: '',
|
text: "",
|
||||||
lastMessage: 'Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.',
|
lastMessage:
|
||||||
},
|
"Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.",
|
||||||
settings: {
|
},
|
||||||
transcription: {
|
settings: {
|
||||||
mode: 'localFirst',
|
transcription: {
|
||||||
modelId: 'parakeet-tdt-0.6b-v3-q8_0',
|
mode: "localFirst",
|
||||||
language: null,
|
modelId: "parakeet-tdt-0.6b-v3-q8_0",
|
||||||
openaiCompatible: {
|
language: null,
|
||||||
endpoint: 'https://api.openai.com/v1/audio/transcriptions',
|
openaiCompatible: {
|
||||||
apiKeyConfigured: false,
|
endpoint: "https://api.openai.com/v1/audio/transcriptions",
|
||||||
model: 'whisper-1',
|
apiKeyConfigured: false,
|
||||||
},
|
model: "whisper-1",
|
||||||
},
|
},
|
||||||
insertion: {
|
},
|
||||||
method: 'clipboardPaste',
|
insertion: {
|
||||||
restoreClipboard: true,
|
method: "clipboardPaste",
|
||||||
waylandManualFallback: true,
|
restoreClipboard: true,
|
||||||
},
|
waylandManualFallback: true,
|
||||||
storage: {
|
},
|
||||||
keepAudio: false,
|
storage: {
|
||||||
audioFormat: 'wav',
|
keepAudio: false,
|
||||||
},
|
audioFormat: "wav",
|
||||||
},
|
},
|
||||||
models: [
|
},
|
||||||
{
|
models: [
|
||||||
model: {
|
{
|
||||||
id: 'parakeet-tdt-0.6b-v3-q8_0',
|
model: {
|
||||||
displayName: 'Parakeet TDT 0.6B v3 Q8',
|
id: "parakeet-tdt-0.6b-v3-q8_0",
|
||||||
repository: 'cstr/parakeet-tdt-0.6b-v3-GGUF',
|
displayName: "Parakeet TDT 0.6B v3 Q8",
|
||||||
filename: 'parakeet-tdt-0.6b-v3-q8_0.gguf',
|
repository: "cstr/parakeet-tdt-0.6b-v3-GGUF",
|
||||||
quantization: 'Q8_0',
|
filename: "parakeet-tdt-0.6b-v3-q8_0.gguf",
|
||||||
sizeBytes: 745_121_600,
|
quantization: "Q8_0",
|
||||||
languages: ['pt', 'en', 'es', 'fr', 'de'],
|
sizeBytes: 745_121_600,
|
||||||
recommendedForSimplePc: true,
|
languages: ["pt", "en", "es", "fr", "de"],
|
||||||
},
|
recommendedForSimplePc: true,
|
||||||
status: 'notInstalled',
|
},
|
||||||
},
|
status: "notInstalled",
|
||||||
],
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function createNativeClient(): NativeClient {
|
function createNativeClient(): NativeClient {
|
||||||
return {
|
return {
|
||||||
invoke: <T>(command: string, args?: Record<string, unknown>) => tauriInvoke<T>(command, args),
|
invoke: <T>(command: string, args?: Record<string, unknown>) =>
|
||||||
};
|
tauriInvoke<T>(command, args),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function element<K extends keyof HTMLElementTagNameMap>(
|
function element<K extends keyof HTMLElementTagNameMap>(
|
||||||
tag: K,
|
tag: K,
|
||||||
className?: string,
|
className?: string,
|
||||||
text?: string,
|
text?: string,
|
||||||
): HTMLElementTagNameMap[K] {
|
): HTMLElementTagNameMap[K] {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
if (className) node.className = className;
|
if (className) node.className = className;
|
||||||
if (text !== undefined) node.textContent = text;
|
if (text !== undefined) node.textContent = text;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
const mib = bytes / 1024 / 1024;
|
const mib = bytes / 1024 / 1024;
|
||||||
return `${mib.toFixed(0)} MiB`;
|
return `${mib.toFixed(0)} MiB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function safeSnapshot(client: NativeClient): Promise<AppSnapshot> {
|
async function safeSnapshot(client: NativeClient): Promise<AppSnapshot> {
|
||||||
try {
|
try {
|
||||||
return await client.invoke<AppSnapshot>('get_app_snapshot');
|
return await client.invoke<AppSnapshot>("get_app_snapshot");
|
||||||
} catch {
|
} catch {
|
||||||
return fallbackSnapshot;
|
return fallbackSnapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTranscriptionApp(
|
export function createTranscriptionApp(
|
||||||
mount: HTMLElement,
|
mount: HTMLElement,
|
||||||
client: NativeClient = createNativeClient(),
|
client: NativeClient = createNativeClient(),
|
||||||
): void {
|
): void {
|
||||||
mount.textContent = '';
|
mount.textContent = "";
|
||||||
|
|
||||||
let snapshot = fallbackSnapshot;
|
let snapshot = fallbackSnapshot;
|
||||||
|
|
||||||
const shell = element('section', 'modal-shell');
|
const shell = element("section", "modal-shell");
|
||||||
shell.setAttribute('aria-labelledby', 'app-title');
|
shell.setAttribute("aria-labelledby", "app-title");
|
||||||
|
|
||||||
const topbar = element('header', 'topbar');
|
const topbar = element("header", "topbar");
|
||||||
const titleGroup = element('div', 'title-group');
|
const titleGroup = element("div", "title-group");
|
||||||
const eyebrow = element('p', 'eyebrow', 'Transcript Desktop');
|
const eyebrow = element("p", "eyebrow", "Transcript Desktop");
|
||||||
const title = element('h1', undefined, 'Transcrição');
|
const title = element("h1", undefined, "Transcrição");
|
||||||
title.id = 'app-title';
|
title.id = "app-title";
|
||||||
const subtitle = element('p', 'subtitle');
|
const subtitle = element("p", "subtitle");
|
||||||
titleGroup.append(eyebrow, title, subtitle);
|
titleGroup.append(eyebrow, title, subtitle);
|
||||||
|
|
||||||
const status = element('span', 'status-pill');
|
const status = element("span", "status-pill");
|
||||||
topbar.append(titleGroup, status);
|
topbar.append(titleGroup, status);
|
||||||
|
|
||||||
const editorLabel = element('label', 'editor-label', 'Texto transcrito');
|
const editorLabel = element("label", "editor-label", "Texto transcrito");
|
||||||
editorLabel.setAttribute('for', 'transcript-editor');
|
editorLabel.setAttribute("for", "transcript-editor");
|
||||||
|
|
||||||
const editor = element('textarea', 'transcript-editor') as HTMLTextAreaElement;
|
const editor = element(
|
||||||
editor.id = 'transcript-editor';
|
"textarea",
|
||||||
editor.placeholder = 'A transcrição aparecerá aqui para revisão antes da inserção.';
|
"transcript-editor",
|
||||||
editor.rows = 10;
|
) as HTMLTextAreaElement;
|
||||||
|
editor.id = "transcript-editor";
|
||||||
|
editor.placeholder =
|
||||||
|
"A transcrição aparecerá aqui para revisão antes da inserção.";
|
||||||
|
editor.rows = 10;
|
||||||
|
|
||||||
const message = element('p', 'message');
|
const message = element("p", "message");
|
||||||
message.setAttribute('role', 'status');
|
message.setAttribute("role", "status");
|
||||||
|
|
||||||
const actions = element('div', 'actions');
|
const actions = element("div", "actions");
|
||||||
const recordButton = element('button', 'primary-button', 'Iniciar gravação') as HTMLButtonElement;
|
const recordButton = element(
|
||||||
const continueButton = element('button', 'secondary-button', '+ Continuar') as HTMLButtonElement;
|
"button",
|
||||||
const copyButton = element('button', 'secondary-button', 'Copiar') as HTMLButtonElement;
|
"primary-button",
|
||||||
const insertButton = element('button', 'secondary-button', 'Inserir no app') as HTMLButtonElement;
|
"Iniciar gravação",
|
||||||
const saveButton = element('button', 'ghost-button', 'Salvar texto') as HTMLButtonElement;
|
) as HTMLButtonElement;
|
||||||
actions.append(recordButton, continueButton, copyButton, insertButton, saveButton);
|
const continueButton = element(
|
||||||
|
"button",
|
||||||
|
"secondary-button",
|
||||||
|
"+ Continuar",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
const copyButton = element(
|
||||||
|
"button",
|
||||||
|
"secondary-button",
|
||||||
|
"Copiar",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
const insertButton = element(
|
||||||
|
"button",
|
||||||
|
"secondary-button",
|
||||||
|
"Inserir no app",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
const saveButton = element(
|
||||||
|
"button",
|
||||||
|
"ghost-button",
|
||||||
|
"Salvar texto",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
actions.append(
|
||||||
|
recordButton,
|
||||||
|
continueButton,
|
||||||
|
copyButton,
|
||||||
|
insertButton,
|
||||||
|
saveButton,
|
||||||
|
);
|
||||||
|
|
||||||
const modelPanel = element('aside', 'model-panel');
|
const modelPanel = element("aside", "model-panel");
|
||||||
const modelTitle = element('h2', undefined, 'Modelo offline');
|
const modelTitle = element("h2", undefined, "Modelo offline");
|
||||||
const modelDetails = element('p');
|
const modelDetails = element("p");
|
||||||
modelPanel.append(modelTitle, modelDetails);
|
modelPanel.append(modelTitle, modelDetails);
|
||||||
|
|
||||||
shell.append(topbar, editorLabel, editor, message, actions, modelPanel);
|
shell.append(topbar, editorLabel, editor, message, actions, modelPanel);
|
||||||
mount.append(shell);
|
mount.append(shell);
|
||||||
|
|
||||||
const render = (next: AppSnapshot): void => {
|
const render = (next: AppSnapshot): void => {
|
||||||
snapshot = next;
|
snapshot = next;
|
||||||
const isRecording = snapshot.recordingStatus === 'recording';
|
const isRecording = snapshot.recordingStatus === "recording";
|
||||||
status.textContent = isRecording ? 'Gravando' : 'Pronto';
|
status.textContent = isRecording ? "Gravando" : "Pronto";
|
||||||
status.dataset.state = snapshot.recordingStatus;
|
status.dataset.state = snapshot.recordingStatus;
|
||||||
subtitle.textContent = `${snapshot.settings.transcription.mode === 'localFirst' ? 'Offline primeiro' : 'API'} · idioma ${snapshot.settings.transcription.language ?? 'automático'}`;
|
subtitle.textContent = `${snapshot.settings.transcription.mode === "localFirst" ? "Offline primeiro" : "API"} · idioma ${snapshot.settings.transcription.language ?? "automático"}`;
|
||||||
editor.value = snapshot.transcript.text;
|
editor.value = snapshot.transcript.text;
|
||||||
message.textContent = snapshot.transcript.lastMessage ?? 'Revise o texto antes de inserir.';
|
message.textContent =
|
||||||
recordButton.textContent = isRecording ? 'Parar e transcrever' : 'Iniciar gravação';
|
snapshot.transcript.lastMessage ?? "Revise o texto antes de inserir.";
|
||||||
|
recordButton.textContent = isRecording
|
||||||
|
? "Parar e transcrever"
|
||||||
|
: "Iniciar gravação";
|
||||||
|
|
||||||
const firstModel = snapshot.models[0]?.model;
|
const firstModel = snapshot.models[0]?.model;
|
||||||
modelDetails.textContent = firstModel
|
modelDetails.textContent = firstModel
|
||||||
? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}`
|
? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}`
|
||||||
: 'Nenhum modelo offline homologado encontrado.';
|
: "Nenhum modelo offline homologado encontrado.";
|
||||||
};
|
};
|
||||||
|
|
||||||
const refresh = async (): Promise<void> => {
|
const refresh = async (): Promise<void> => {
|
||||||
render(await safeSnapshot(client));
|
render(await safeSnapshot(client));
|
||||||
};
|
};
|
||||||
|
|
||||||
recordButton.addEventListener('click', () => {
|
const showError = (error: unknown): void => {
|
||||||
void (async () => {
|
message.textContent = error instanceof Error ? error.message : String(error);
|
||||||
const command = snapshot.recordingStatus === 'recording'
|
};
|
||||||
? 'stop_recording_and_transcribe'
|
|
||||||
: 'start_recording';
|
|
||||||
render(await client.invoke<AppSnapshot>(command));
|
|
||||||
})().catch((error: unknown) => {
|
|
||||||
message.textContent = error instanceof Error ? error.message : String(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
continueButton.addEventListener('click', () => {
|
const toggleRecording = async (): Promise<void> => {
|
||||||
void client.invoke<AppSnapshot>('start_recording').then(render).catch((error: unknown) => {
|
const command =
|
||||||
message.textContent = error instanceof Error ? error.message : String(error);
|
snapshot.recordingStatus === "recording"
|
||||||
});
|
? "stop_recording_and_transcribe"
|
||||||
});
|
: "start_recording";
|
||||||
|
render(await client.invoke<AppSnapshot>(command));
|
||||||
|
};
|
||||||
|
|
||||||
copyButton.addEventListener('click', () => {
|
recordButton.addEventListener("click", () => {
|
||||||
void navigator.clipboard
|
void toggleRecording().catch(showError);
|
||||||
.writeText(editor.value)
|
});
|
||||||
.then(() => {
|
|
||||||
message.textContent = 'Texto copiado para a área de transferência.';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
message.textContent = 'Não foi possível copiar automaticamente. Selecione o texto manualmente.';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
insertButton.addEventListener('click', () => {
|
continueButton.addEventListener("click", () => {
|
||||||
void client
|
void client
|
||||||
.invoke<{ message: string }>('request_insert_text', { text: editor.value })
|
.invoke<AppSnapshot>("start_recording")
|
||||||
.then((result) => {
|
.then(render)
|
||||||
message.textContent = result.message;
|
.catch(showError);
|
||||||
})
|
});
|
||||||
.catch((error: unknown) => {
|
|
||||||
message.textContent = error instanceof Error ? error.message : String(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
saveButton.addEventListener('click', () => {
|
copyButton.addEventListener("click", () => {
|
||||||
const blob = new Blob([editor.value], { type: 'text/plain;charset=utf-8' });
|
void navigator.clipboard
|
||||||
const url = URL.createObjectURL(blob);
|
.writeText(editor.value)
|
||||||
const link = document.createElement('a');
|
.then(() => {
|
||||||
link.href = url;
|
message.textContent = "Texto copiado para a área de transferência.";
|
||||||
link.download = 'transcricao.txt';
|
})
|
||||||
link.click();
|
.catch(() => {
|
||||||
URL.revokeObjectURL(url);
|
message.textContent =
|
||||||
message.textContent = 'Arquivo de texto preparado para download.';
|
"Não foi possível copiar automaticamente. Selecione o texto manualmente.";
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
void refresh();
|
insertButton.addEventListener("click", () => {
|
||||||
|
void client
|
||||||
|
.invoke<{ message: string }>("request_insert_text", {
|
||||||
|
text: editor.value,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
message.textContent = result.message;
|
||||||
|
})
|
||||||
|
.catch(showError);
|
||||||
|
});
|
||||||
|
|
||||||
|
saveButton.addEventListener("click", () => {
|
||||||
|
const blob = new Blob([editor.value], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "transcricao.txt";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
message.textContent = "Arquivo de texto preparado para download.";
|
||||||
|
});
|
||||||
|
|
||||||
|
void listen("transcript://toggle-recording", () => {
|
||||||
|
void toggleRecording().catch(showError);
|
||||||
|
}).catch(() => {
|
||||||
|
// Browser preview and tests do not expose the Tauri event bridge.
|
||||||
|
});
|
||||||
|
|
||||||
|
void refresh();
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user