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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable ScribeWatch changes are documented here.

## [Unreleased]

### Added
- Ordered provider + model fallback chains for Workflows and Quick Transcribe, including multiple models from the same provider.
- Live model discovery per provider, per-route optional fallback timeouts, and durable per-route attempt history.
- Jobs now record the provider/model that actually succeeded and expose fallback attempts in the UI.

### Changed
- Transcription processing no longer inherits a provider-wide fixed request timeout; long audio waits by default unless a route explicitly defines a fallback timeout.
- Legacy `providerId + model` workflow and Quick Transcribe payloads remain accepted during the v0.2.x migration window.

## [0.2.0] - 2026-09-16

### Added
Expand Down
19 changes: 19 additions & 0 deletions README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Le cas d’usage naturel est **notes vocales → Obsidian**, mais rien n’est v
- **Watch folders** — automatisez les nouveaux enregistrements d’un serveur, NAS ou dossier synchronisé.
- **Prêt pour Obsidian** — noms propres, titre H1, propriétés YAML et tags.
- **Votre moteur de transcription** — endpoint STT compatible OpenAI, notamment Speaches/Whisper.
- **Chaînes de fallback résilientes** — mélangez providers et modèles dans l’ordre voulu. Si une route échoue, ScribeWatch essaie automatiquement la suivante — y compris un autre modèle du même provider.
- **Aucun LLM obligatoire** — titres et formatage sont déterministes ; la transcription reste la source de vérité.
- **Publication sûre** — le Markdown est publié avant l’archivage de l’audio, sans écraser une note existante.
- **Petite stack auto-hébergée** — backend Rust/Axum, UI SvelteKit, SQLite, un conteneur.
Expand All @@ -56,6 +57,22 @@ Le cas d’usage naturel est **notes vocales → Obsidian**, mais rien n’est v
<img src="docs/screenshots/workflow-folders.png" width="49%" alt="Dossiers d’un workflow ScribeWatch">
</p>

## 🔁 Fallbacks provider + modèle

Chaque route de transcription est un couple explicite **provider + modèle**. ScribeWatch aspire la liste des modèles exposés par chaque provider configuré : vous choisissez donc exactement la chaîne voulue, y compris plusieurs modèles d’un même provider.

```text
Speaches / whisper-large-v3
↓ échec
Speaches / distil-whisper-large-v3
↓ échec
OpenAI / gpt-4o-transcribe
Markdown
```

Il n’y a **aucun timeout de traitement par défaut**, ce qui évite de pénaliser les longs enregistrements. Chaque route peut cependant définir son propre délai de fallback (`Jamais`, 10/30/60 minutes ou personnalisé). Une panne réseau, erreur provider, rate limit ou réponse invalide peut passer à la route suivante ; une annulation utilisateur stoppe toute la chaîne. L’historique conserve chaque tentative et le provider/modèle réellement utilisé.

## 🚀 Installation rapide

Prérequis : Docker Engine + Compose et un endpoint de transcription compatible OpenAI.
Expand Down Expand Up @@ -92,6 +109,8 @@ tags:
- transcription
- scribewatch
source: "Enregistrement 42.m4a"
provider: "Speaches local"
model: "whisper-large-v3"
language: "fr"
---

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ It is especially useful as a **voice notes → Obsidian** bridge, but nothing is
- **Watch folders** — automatically process new recordings from a server, NAS or mounted sync folder.
- **Obsidian-friendly by default** — clean filenames, H1 titles, YAML properties and tags.
- **Bring your own transcription engine** — works with OpenAI-compatible STT endpoints such as Speaches/Whisper services.
- **Resilient transcription chains** — mix providers and models in any order. If one route fails, ScribeWatch automatically tries the next — even another model from the same provider.
- **No LLM required** — titles and Markdown formatting are deterministic; the transcript stays authoritative.
- **Safe publication** — Markdown is written before workflow audio is archived, and existing notes are never silently overwritten.
- **Small self-hosted stack** — Rust/Axum backend, SvelteKit UI, SQLite state, one container.
Expand All @@ -56,6 +57,22 @@ It is especially useful as a **voice notes → Obsidian** bridge, but nothing is
<img src="docs/screenshots/workflow-folders.png" width="49%" alt="ScribeWatch workflow folders">
</p>

## 🔁 Provider + model fallbacks

Every transcription route is an explicit **provider + model** pair. ScribeWatch discovers the models exposed by each configured provider, so you choose the exact chain — including multiple models from the same provider.

```text
Speaches / whisper-large-v3
↓ failed
Speaches / distil-whisper-large-v3
↓ failed
OpenAI / gpt-4o-transcribe
Markdown
```

There is **no processing timeout by default**, which matters for long recordings. Each route can optionally define its own fallback timeout (`Never`, 10/30/60 minutes or custom). Connection failures, provider errors, rate limits and invalid responses can fall through; explicit user cancellation stops the whole chain. Jobs keep the attempt history and record the provider/model that actually succeeded.

## 🚀 Quick start

Requirements: Docker Engine + Compose and an OpenAI-compatible transcription endpoint.
Expand Down Expand Up @@ -92,6 +109,8 @@ tags:
- transcription
- scribewatch
source: "Recording 42.m4a"
provider: "Local Speaches"
model: "whisper-large-v3"
language: "en"
---

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/app.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}

function appendQuickOptions(form: FormData, options: QuickOptions) {
form.append('providerId', options.providerId);
form.append('model', options.model ?? '');
if (options.providerId) form.append('providerId', options.providerId);
if (options.model) form.append('model', options.model);
if (options.transcriptionChain) form.append('transcriptionChain', JSON.stringify(options.transcriptionChain));
form.append('language', options.language ?? '');
form.append('outputKind', options.outputKind);
form.append('outputDir', options.outputDir ?? '');
Expand Down
127 changes: 127 additions & 0 deletions frontend/src/lib/components/TranscriptionChainEditor.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<script lang="ts">
import { api } from '$lib/api';
import { addRoute, moveRoute, removeRoute, updateRoute } from '$lib/transcription-chain.js';
import type { Provider, TranscriptionRoute } from '$lib/types';

export let value: TranscriptionRoute[] = [];
export let providers: Provider[] = [];
export let onchange: (routes: TranscriptionRoute[]) => void = () => {};
export let notify: (type:'success'|'error', message:string) => void = () => {};

let modelsByProvider: Record<string,string[]> = {};
let loadingByProvider: Record<string,boolean> = {};
let errorsByProvider: Record<string,string> = {};

async function discover(providerId:string, force=false) {
if (!providerId) return;
if (!force && (modelsByProvider[providerId] || loadingByProvider[providerId] || errorsByProvider[providerId])) return;
loadingByProvider={...loadingByProvider,[providerId]:true};
if (force) {
const next={...errorsByProvider}; delete next[providerId]; errorsByProvider=next;
}
try {
const result=await api.models(providerId);
modelsByProvider={...modelsByProvider,[providerId]:result.models};
const next={...errorsByProvider}; delete next[providerId]; errorsByProvider=next;
if (result.models.length===0) notify('error','Provider returned no models.');
} catch(e) {
const message=e instanceof Error?e.message:String(e);
errorsByProvider={...errorsByProvider,[providerId]:message};
} finally {
loadingByProvider={...loadingByProvider,[providerId]:false};
}
}

function visibleProviders(route:TranscriptionRoute) {
return providers.filter((provider)=>provider.enabled || provider.id===route.providerId);
}
function modelOptions(route:TranscriptionRoute) {
const reported=modelsByProvider[route.providerId]??[];
return [...new Set([...(route.model?[route.model]:[]),...reported])];
}
function update(index:number, patch:Partial<TranscriptionRoute>) {
onchange(updateRoute(value,index,patch));
}
function providerChanged(index:number, event:Event) {
const providerId=(event.currentTarget as HTMLSelectElement).value;
update(index,{providerId,model:''});
void discover(providerId,true);
}
function modelChanged(index:number, event:Event) {
update(index,{model:(event.currentTarget as HTMLSelectElement).value});
}
function timeoutChoice(route:TranscriptionRoute) {
const seconds=route.fallbackAfterSeconds;
if (!seconds) return 'never';
if ([600,1800,3600].includes(seconds)) return String(seconds);
return 'custom';
}
function timeoutChanged(index:number,event:Event) {
const choice=(event.currentTarget as HTMLSelectElement).value;
if (choice==='never') update(index,{fallbackAfterSeconds:undefined});
else if (choice==='custom') update(index,{fallbackAfterSeconds:value[index]?.fallbackAfterSeconds||60});
else update(index,{fallbackAfterSeconds:Number(choice)});
}
function customTimeoutChanged(index:number,event:Event) {
const minutes=Math.max(1,Number((event.currentTarget as HTMLInputElement).value)||1);
update(index,{fallbackAfterSeconds:Math.round(minutes*60)});
}
function add() { onchange(addRoute(value.length?value:[{providerId:'',model:''}])); }
function remove(index:number) { onchange(removeRoute(value,index)); }
function move(index:number,delta:number) { onchange(moveRoute(value,index,index+delta)); }

$: for (const route of value) {
if (route.providerId) void discover(route.providerId);
}
</script>

<div class="chain-editor">
<div class="chain-head">
<div><strong>Transcription chain</strong><p class="help">Choose the exact provider + model order. If one route fails, ScribeWatch tries the next.</p></div>
</div>
{#each value as route,index (index)}
<div class="chain-route">
<div class="chain-route-label"><strong>{index===0?'Primary':`Fallback ${index}`}</strong><span>#{index+1}</span></div>
<div class="field">
<label for={`route-provider-${index}`}>Provider</label>
<select id={`route-provider-${index}`} class="select" value={route.providerId} on:change={(event)=>providerChanged(index,event)}>
<option value="">Choose provider</option>
{#each visibleProviders(route) as provider}
<option value={provider.id}>{provider.name}{provider.enabled?'':' (disabled)'}</option>
{/each}
</select>
</div>
<div class="field">
<label for={`route-model-${index}`}>Model</label>
<select id={`route-model-${index}`} class="select" value={route.model} disabled={!route.providerId||loadingByProvider[route.providerId]} on:change={(event)=>modelChanged(index,event)}>
<option value="">{loadingByProvider[route.providerId]?'Loading models…':'Choose model'}</option>
{#each modelOptions(route) as model}
<option value={model}>{model}{errorsByProvider[route.providerId]&&model===route.model?' (saved — discovery unavailable)':''}</option>
{/each}
</select>
{#if errorsByProvider[route.providerId]}
<span class="help error-inline">Model discovery unavailable. <button class="link-button" on:click={()=>discover(route.providerId,true)}>Retry</button></span>
{/if}
</div>
<div class="field chain-timeout">
<label for={`route-timeout-${index}`}>Fallback after</label>
<select id={`route-timeout-${index}`} class="select" value={timeoutChoice(route)} on:change={(event)=>timeoutChanged(index,event)}>
<option value="never">Never</option>
<option value="600">10 min</option>
<option value="1800">30 min</option>
<option value="3600">60 min</option>
<option value="custom">Custom</option>
</select>
{#if timeoutChoice(route)==='custom'}
<div class="timeout-custom"><input class="input" type="number" min="1" value={Math.max(1,Math.round((route.fallbackAfterSeconds??60)/60))} on:input={(event)=>customTimeoutChanged(index,event)} /><span>min</span></div>
{/if}
</div>
<div class="chain-actions" aria-label={`Actions for route ${index+1}`}>
<button class="btn icon-btn" disabled={index===0} title="Move up" on:click={()=>move(index,-1)}>↑</button>
<button class="btn icon-btn" disabled={index===value.length-1} title="Move down" on:click={()=>move(index,1)}>↓</button>
<button class="btn icon-btn danger" disabled={value.length===1} title="Remove route" on:click={()=>remove(index)}>×</button>
</div>
</div>
{/each}
<button class="btn chain-add" on:click={add}>+ Add fallback</button>
</div>
30 changes: 30 additions & 0 deletions frontend/src/lib/transcription-chain.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// @ts-check
/** @typedef {import('./types').TranscriptionRoute} TranscriptionRoute */

/** @param {TranscriptionRoute[]} routes @returns {TranscriptionRoute[]} */
export function addRoute(routes) {
return [...routes, { providerId: '', model: '' }];
}

/** @param {TranscriptionRoute[]} routes @param {number} index @returns {TranscriptionRoute[]} */
export function removeRoute(routes, index) {
if (routes.length <= 1 || index < 0 || index >= routes.length) return [...routes];
return routes.filter((_, current) => current !== index);
}

/** @param {TranscriptionRoute[]} routes @param {number} from @param {number} to @returns {TranscriptionRoute[]} */
export function moveRoute(routes, from, to) {
if (from < 0 || from >= routes.length || to < 0 || to >= routes.length || from === to) {
return [...routes];
}
const next = [...routes];
const [route] = next.splice(from, 1);
next.splice(to, 0, route);
return next;
}

/** @param {TranscriptionRoute[]} routes @param {number} index @param {Partial<TranscriptionRoute>} patch @returns {TranscriptionRoute[]} */
export function updateRoute(routes, index, patch) {
if (index < 0 || index >= routes.length) return [...routes];
return routes.map((route, current) => current === index ? { ...route, ...patch } : route);
}
29 changes: 28 additions & 1 deletion frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ export type JobKind = 'workflow' | 'quick';
export type QuickSourceKind = 'server' | 'upload';
export type QuickOutputKind = 'server' | 'client';

export interface TranscriptionRoute {
providerId: string;
model: string;
fallbackAfterSeconds?: number;
}

export type TranscriptionAttemptOutcome = 'success' | 'failed' | 'timed_out' | 'cancelled';

export interface TranscriptionAttempt {
run: number;
routeIndex: number;
providerId: string;
providerName: string;
model: string;
startedAtMs: number;
finishedAtMs: number;
outcome: TranscriptionAttemptOutcome;
error?: string;
}

export interface Provider {
id: string;
name: string;
Expand Down Expand Up @@ -38,6 +58,7 @@ export interface Workflow {
tags: string[];
providerId: string;
model: string;
transcriptionChain?: TranscriptionRoute[];
language?: string;
markdown: MarkdownOptions;
enabled: boolean;
Expand All @@ -57,6 +78,11 @@ export interface Job {
workflowId?: string;
quick?: QuickJobMeta;
providerId: string;
transcriptionChain: TranscriptionRoute[];
transcriptionAttempts: TranscriptionAttempt[];
usedProviderId?: string;
usedProviderName?: string;
usedModel?: string;
originalName: string;
sourcePath: string;
sourceSize: number;
Expand All @@ -74,8 +100,9 @@ export interface Job {
}

export interface QuickOptions {
providerId: string;
providerId?: string;
model?: string;
transcriptionChain?: TranscriptionRoute[];
language?: string;
outputKind: QuickOutputKind;
outputDir?: string;
Expand Down
Loading