Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d263be2ac1 | ||
|
|
1c8c6ad7e8 | ||
|
|
023bc0218b | ||
|
|
634506f902 | ||
|
|
8c7f4e8008 | ||
|
|
b8d718caa6 | ||
|
|
d92005bf95 | ||
|
|
e90d154b83 | ||
|
|
fa749b571d | ||
|
|
ef188e1f67 | ||
|
|
f7b9695418 | ||
|
|
b4c0589b04 | ||
|
|
66c441b17f | ||
|
|
94bc704950 | ||
|
|
7900d2d9f2 | ||
|
|
e0396df7b0 | ||
|
|
ad89735822 | ||
|
|
f0b5890eba | ||
|
|
8df1ab9817 | ||
|
|
34a165fc05 | ||
|
|
8f4e5cc099 | ||
|
|
16f9ac2ab8 | ||
|
|
cd325102e2 | ||
|
|
d220158dd7 | ||
|
|
8670e19acc |
@@ -46,8 +46,45 @@ jobs:
|
|||||||
shell: powershell
|
shell: powershell
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Setup Azure Artifact Signing
|
||||||
|
shell: powershell
|
||||||
|
env:
|
||||||
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||||
|
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||||
|
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||||
|
run: |
|
||||||
|
if (-not $env:AZURE_CLIENT_ID) {
|
||||||
|
Write-Host "No Azure signing secrets configured, skipping code signing setup"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Setting up Azure Artifact Signing..."
|
||||||
|
|
||||||
|
# Install Artifact Signing client tools
|
||||||
|
nuget install Microsoft.ArtifactSigning.Client -x -OutputDirectory .\signing-tools
|
||||||
|
$dlibPath = (Resolve-Path ".\signing-tools\Microsoft.ArtifactSigning.Client*\bin\x64\Azure.CodeSigning.Dlib.dll").Path
|
||||||
|
|
||||||
|
# Write metadata.json
|
||||||
|
@{
|
||||||
|
Endpoint = $env:AZURE_SIGNING_ENDPOINT
|
||||||
|
CodeSigningAccountName = $env:AZURE_SIGNING_ACCOUNT
|
||||||
|
CertificateProfileName = $env:AZURE_CERT_PROFILE
|
||||||
|
} | ConvertTo-Json | Out-File -Encoding UTF8 metadata.json
|
||||||
|
$metadataPath = (Resolve-Path "metadata.json").Path
|
||||||
|
|
||||||
|
# Inject signCommand into tauri.conf.json for this build
|
||||||
|
$conf = Get-Content src-tauri\tauri.conf.json -Raw | ConvertFrom-Json
|
||||||
|
$signCmd = "signtool.exe sign /v /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 /dlib `"$dlibPath`" /dmdf `"$metadataPath`" %1"
|
||||||
|
$conf.bundle.windows | Add-Member -NotePropertyName "signCommand" -NotePropertyValue $signCmd -Force
|
||||||
|
$conf | ConvertTo-Json -Depth 10 | Set-Content src-tauri\tauri.conf.json -Encoding UTF8
|
||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
shell: powershell
|
shell: powershell
|
||||||
|
env:
|
||||||
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||||
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
run: npm run tauri build
|
run: npm run tauri build
|
||||||
|
|
||||||
- name: Upload to release
|
- name: Upload to release
|
||||||
|
|||||||
26
CLAUDE.md
26
CLAUDE.md
@@ -11,9 +11,11 @@ Local Transcription is a cross-platform desktop application for real-time speech
|
|||||||
**Key Features:**
|
**Key Features:**
|
||||||
- Cross-platform desktop app (Windows, macOS, Linux) via Tauri v2 + Svelte 5
|
- Cross-platform desktop app (Windows, macOS, Linux) via Tauri v2 + Svelte 5
|
||||||
- Headless Python backend with FastAPI control API
|
- Headless Python backend with FastAPI control API
|
||||||
- Dual transcription modes: local Whisper or cloud Deepgram (managed/BYOK)
|
- Cloud-first: defaults to Deepgram (BYOK) transcription; local Whisper also supported
|
||||||
|
- Settings UI hides local-only options (model, VAD, timing) when in cloud mode
|
||||||
|
- Start button gated on API key / login — shows guidance if not configured
|
||||||
|
- Shared Captions: create rooms, share via codes, join with one click (hosted at caption.shadowdao.com)
|
||||||
- Built-in web server for OBS browser source at `http://localhost:8080`
|
- Built-in web server for OBS browser source at `http://localhost:8080`
|
||||||
- Optional multi-user sync via Node.js server
|
|
||||||
- CUDA, MPS (Apple Silicon), and CPU support
|
- CUDA, MPS (Apple Silicon), and CPU support
|
||||||
- Auto-updates, custom fonts, configurable colors
|
- Auto-updates, custom fonts, configurable colors
|
||||||
|
|
||||||
@@ -273,9 +275,29 @@ All per-OS build workflows can be re-run independently via `workflow_dispatch` w
|
|||||||
- `Info.plist` must include `NSMicrophoneUsageDescription` for mic access
|
- `Info.plist` must include `NSMicrophoneUsageDescription` for mic access
|
||||||
- No CUDA builds — CPU/MPS only
|
- No CUDA builds — CPU/MPS only
|
||||||
|
|
||||||
|
## Code Signing
|
||||||
|
|
||||||
|
Code signing is configured for Windows and macOS to eliminate install warnings (SmartScreen / Gatekeeper). See [SIGNING.md](SIGNING.md) for full setup details.
|
||||||
|
|
||||||
|
**Status (as of 2026-04-10):** CI workflow changes are committed. Waiting on identity verification for both platforms before secrets can be configured.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
- macOS: Tauri auto-signs when `APPLE_CERTIFICATE` and related env vars are set in CI. Notarization uses App Store Connect API key.
|
||||||
|
- Windows: Azure Artifact Signing via `signtool.exe` + dlib. CI workflow injects `signCommand` into `tauri.conf.json` at build time when `AZURE_CLIENT_ID` is set.
|
||||||
|
- Both are no-ops when secrets aren't configured — unsigned builds work as before.
|
||||||
|
|
||||||
|
**Key files:**
|
||||||
|
- `src-tauri/Entitlements.plist` — macOS hardened runtime entitlements (mic, network)
|
||||||
|
- `src-tauri/Info.plist` — macOS microphone usage description
|
||||||
|
- `.gitea/workflows/build-app-macos.yml` — Apple signing + notarization
|
||||||
|
- `.gitea/workflows/build-app-windows.yml` — Azure Artifact Signing
|
||||||
|
|
||||||
|
**Secrets required (12 total):** See [SIGNING.md](SIGNING.md) for the full list — 6 Apple secrets, 6 Azure secrets.
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|
||||||
- [README.md](README.md) — User-facing documentation
|
- [README.md](README.md) — User-facing documentation
|
||||||
- [BUILD.md](BUILD.md) — Detailed build instructions
|
- [BUILD.md](BUILD.md) — Detailed build instructions
|
||||||
- [INSTALL.md](INSTALL.md) — Installation guide
|
- [INSTALL.md](INSTALL.md) — Installation guide
|
||||||
|
- [SIGNING.md](SIGNING.md) — Code signing setup guide
|
||||||
- [server/nodejs/README.md](server/nodejs/README.md) — Node.js server setup
|
- [server/nodejs/README.md](server/nodejs/README.md) — Node.js server setup
|
||||||
|
|||||||
85
README.md
85
README.md
@@ -7,14 +7,14 @@ A real-time speech-to-text desktop application for streamers. Runs locally on yo
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-Time Transcription**: Live speech-to-text using Whisper models with minimal latency
|
- **Real-Time Transcription**: Live speech-to-text using Whisper models with minimal latency
|
||||||
|
- **Cloud-First**: Defaults to Deepgram cloud transcription — get started with just an API key
|
||||||
- **Cross-Platform**: Native desktop app for Windows, macOS, and Linux via [Tauri](https://tauri.app/)
|
- **Cross-Platform**: Native desktop app for Windows, macOS, and Linux via [Tauri](https://tauri.app/)
|
||||||
- **Dual Transcription Modes**: Local (Whisper) or cloud (Deepgram) with managed billing or BYOK
|
- **Dual Transcription Modes**: Cloud (Deepgram) or local (Whisper) with automatic GPU/CPU detection
|
||||||
- **CPU & GPU Support**: Automatic detection of CUDA (NVIDIA), MPS (Apple Silicon), or CPU fallback
|
- **Shared Captions**: Create a room and share a code so others can join — no server setup needed
|
||||||
- **Advanced Voice Detection**: Dual-layer VAD (WebRTC + Silero) for accurate speech detection
|
|
||||||
- **OBS Integration**: Built-in web server for browser source capture at `http://localhost:8080`
|
- **OBS Integration**: Built-in web server for browser source capture at `http://localhost:8080`
|
||||||
- **Multi-User Sync**: Optional Node.js server to sync transcriptions across multiple users
|
|
||||||
- **Custom Fonts**: Support for system fonts, web-safe fonts, Google Fonts, and custom font files
|
- **Custom Fonts**: Support for system fonts, web-safe fonts, Google Fonts, and custom font files
|
||||||
- **Customizable Colors**: User-configurable colors for name, text, and background
|
- **Customizable Colors**: User-configurable colors for name, text, and background
|
||||||
|
- **Advanced Voice Detection**: Dual-layer VAD (WebRTC + Silero) for accurate speech detection
|
||||||
- **Noise Suppression**: Built-in audio preprocessing to reduce background noise
|
- **Noise Suppression**: Built-in audio preprocessing to reduce background noise
|
||||||
- **Auto-Updates**: Automatic update checking with release notes display
|
- **Auto-Updates**: Automatic update checking with release notes display
|
||||||
|
|
||||||
@@ -87,27 +87,30 @@ For detailed build instructions, see [BUILD.md](BUILD.md).
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Standalone Mode
|
### Quick Setup (Cloud — Recommended)
|
||||||
|
|
||||||
1. Launch the application
|
1. Launch the application
|
||||||
2. Select your microphone from the audio device dropdown
|
2. Open **Settings** — the transcription mode defaults to **Cloud (Deepgram)**
|
||||||
3. Choose a Whisper model (smaller = faster, larger = more accurate):
|
3. Get a free API key at [console.deepgram.com](https://console.deepgram.com) and paste it in Settings
|
||||||
|
4. Select your microphone from the audio device dropdown
|
||||||
|
5. Click **Start Transcription**
|
||||||
|
6. Transcriptions appear in the main window and at `http://localhost:8080`
|
||||||
|
|
||||||
|
> The Start button is disabled until an API key is entered. Local-only settings (model, VAD, timing) are hidden in cloud mode to keep things simple.
|
||||||
|
|
||||||
|
### Local Mode (Whisper)
|
||||||
|
|
||||||
|
For offline/on-device transcription, switch to **Local (Whisper)** in Settings:
|
||||||
|
|
||||||
|
1. Choose a Whisper model (smaller = faster, larger = more accurate):
|
||||||
- `tiny.en` / `tiny` — Fastest, good for quick captions
|
- `tiny.en` / `tiny` — Fastest, good for quick captions
|
||||||
- `base.en` / `base` — Balanced speed and accuracy
|
- `base.en` / `base` — Balanced speed and accuracy
|
||||||
- `small.en` / `small` — Better accuracy
|
- `small.en` / `small` — Better accuracy
|
||||||
- `medium.en` / `medium` — High accuracy
|
- `medium.en` / `medium` — High accuracy
|
||||||
- `large-v3` — Best accuracy (requires more resources)
|
- `large-v3` — Best accuracy (requires more resources)
|
||||||
4. Click **Start** to begin transcription
|
2. Select compute device (Auto/CUDA/CPU) and compute type
|
||||||
5. Transcriptions appear in the main window and at `http://localhost:8080`
|
3. Tune VAD sensitivity and timing settings as needed
|
||||||
|
4. Click **Start Transcription**
|
||||||
### Remote Transcription (Deepgram)
|
|
||||||
|
|
||||||
Instead of local Whisper models, you can use cloud-based transcription:
|
|
||||||
|
|
||||||
- **Managed mode**: Sign up via the transcription proxy for metered billing
|
|
||||||
- **BYOK mode**: Bring your own Deepgram API key for direct access
|
|
||||||
|
|
||||||
Configure in Settings > Remote Transcription.
|
|
||||||
|
|
||||||
### OBS Browser Source Setup
|
### OBS Browser Source Setup
|
||||||
|
|
||||||
@@ -117,18 +120,42 @@ Configure in Settings > Remote Transcription.
|
|||||||
4. Set dimensions (e.g., 1920x300)
|
4. Set dimensions (e.g., 1920x300)
|
||||||
5. Check "Shutdown source when not visible" for performance
|
5. Check "Shutdown source when not visible" for performance
|
||||||
|
|
||||||
### Multi-User Mode (Optional)
|
### Shared Captions (Multi-User)
|
||||||
|
|
||||||
For syncing transcriptions across multiple users (e.g., multi-host streams or translation teams):
|
Share live captions across multiple users using the hosted service at `https://caption.shadowdao.com/` — no server setup required.
|
||||||
|
|
||||||
1. Deploy the Node.js server (see [server/nodejs/README.md](server/nodejs/README.md))
|
#### Creating a Room
|
||||||
2. In the app settings, enable **Server Sync**
|
|
||||||
3. Enter the server URL (e.g., `http://your-server:3000/api/send`)
|
1. Open **Settings** and enable **Shared Captions**
|
||||||
4. Set a room name and passphrase (shared with other users)
|
2. Click **Create Room** — this generates a room name and passphrase automatically
|
||||||
5. In OBS, use the server's display URL with your room name:
|
3. A **share code** is generated and copied to your clipboard
|
||||||
```
|
4. Send the share code to anyone who should join
|
||||||
http://your-server:3000/display?room=YOURROOM×tamps=true&maxlines=50
|
|
||||||
```
|
#### Joining a Room
|
||||||
|
|
||||||
|
1. Open **Settings** and enable **Shared Captions**
|
||||||
|
2. Paste the share code you received into the **"Paste share code to join"** field
|
||||||
|
3. Click **Join** — the server URL, room, and passphrase are auto-filled
|
||||||
|
4. Click **Save**
|
||||||
|
|
||||||
|
#### Sharing an Existing Room
|
||||||
|
|
||||||
|
If you already have a room configured and want to invite others:
|
||||||
|
|
||||||
|
1. Open **Settings** and scroll to **Shared Captions**
|
||||||
|
2. Click **Share Current Room** — generates a share code from your current config and copies it to the clipboard
|
||||||
|
3. Send the code to others
|
||||||
|
|
||||||
|
#### OBS Display for Shared Rooms
|
||||||
|
|
||||||
|
In OBS, add a Browser source pointing to the server's display URL:
|
||||||
|
```
|
||||||
|
https://caption.shadowdao.com/display?room=YOURROOM×tamps=true&maxlines=50
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Self-Hosting
|
||||||
|
|
||||||
|
You can also self-host the sync server. See [server/nodejs/README.md](server/nodejs/README.md) for setup instructions, then enter your own server URL in the Shared Captions settings.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -144,7 +171,7 @@ Settings are stored at `~/.local-transcription/config.yaml` and can be modified
|
|||||||
| `transcription.silero_sensitivity` | VAD sensitivity (0-1, lower = more sensitive) | `0.4` |
|
| `transcription.silero_sensitivity` | VAD sensitivity (0-1, lower = more sensitive) | `0.4` |
|
||||||
| `transcription.post_speech_silence_duration` | Silence before finalizing (seconds) | `0.3` |
|
| `transcription.post_speech_silence_duration` | Silence before finalizing (seconds) | `0.3` |
|
||||||
| `transcription.continuous_mode` | Fast speaker mode for quick talkers | `false` |
|
| `transcription.continuous_mode` | Fast speaker mode for quick talkers | `false` |
|
||||||
| `remote.mode` | Transcription mode (local/managed/byok) | `local` |
|
| `remote.mode` | Transcription mode (local/managed/byok) | `byok` |
|
||||||
| `display.show_timestamps` | Show timestamps with transcriptions | `true` |
|
| `display.show_timestamps` | Show timestamps with transcriptions | `true` |
|
||||||
| `display.fade_after_seconds` | Fade out time (0 = never) | `10` |
|
| `display.fade_after_seconds` | Fade out time (0 = never) | `10` |
|
||||||
| `display.font_source` | Font type (System Font/Web-Safe/Google Font/Custom File) | `System Font` |
|
| `display.font_source` | Font type (System Font/Web-Safe/Google Font/Custom File) | `System Font` |
|
||||||
|
|||||||
136
SIGNING.md
Normal file
136
SIGNING.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Code Signing Setup
|
||||||
|
|
||||||
|
This document explains how to configure code signing for Local Transcription so that Windows and macOS installers are trusted by the operating system.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Without code signing:
|
||||||
|
- **Windows**: SmartScreen shows "Windows protected your PC" warnings
|
||||||
|
- **macOS**: Gatekeeper blocks the app — "app can't be opened because it is from an unidentified developer"
|
||||||
|
|
||||||
|
The CI/CD workflows are configured to sign automatically when the required secrets are present. Without secrets, builds still work — they just produce unsigned installers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows — Azure Artifact Signing
|
||||||
|
|
||||||
|
**Cost**: ~$9.99/month (up to 5,000 signatures)
|
||||||
|
|
||||||
|
### 1. Create an Azure Account
|
||||||
|
|
||||||
|
Sign up at https://azure.microsoft.com if you don't already have one.
|
||||||
|
|
||||||
|
### 2. Set Up Artifact Signing
|
||||||
|
|
||||||
|
1. In the Azure Portal, search for **Artifact Signing**
|
||||||
|
2. Create a new **Artifact Signing Account**
|
||||||
|
- Choose a region (e.g., West US 2) — note this for the endpoint URL
|
||||||
|
- The endpoint will be like `https://wus2.codesigning.azure.net/`
|
||||||
|
3. Complete **Identity Verification** (required before you can create certificate profiles)
|
||||||
|
4. Create a **Certificate Profile** with type "Public Trust" for code signing
|
||||||
|
|
||||||
|
### 3. Create an App Registration (Service Principal)
|
||||||
|
|
||||||
|
This allows CI to authenticate to Azure:
|
||||||
|
|
||||||
|
1. Go to **Azure Active Directory** > **App registrations** > **New registration**
|
||||||
|
2. Name it (e.g., `local-transcription-signing`)
|
||||||
|
3. After creation, note the **Application (client) ID** and **Directory (tenant) ID**
|
||||||
|
4. Go to **Certificates & secrets** > **New client secret** — note the secret value
|
||||||
|
5. Grant the app registration the **Artifact Signing Certificate Profile Signer** role on your Artifact Signing Account
|
||||||
|
|
||||||
|
### 4. Add Gitea Secrets
|
||||||
|
|
||||||
|
In your Gitea repository, go to **Settings** > **Actions** > **Secrets** and add:
|
||||||
|
|
||||||
|
| Secret Name | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| `AZURE_CLIENT_ID` | App registration Application (client) ID |
|
||||||
|
| `AZURE_CLIENT_SECRET` | App registration client secret value |
|
||||||
|
| `AZURE_TENANT_ID` | Directory (tenant) ID |
|
||||||
|
| `AZURE_SIGNING_ENDPOINT` | Artifact Signing endpoint URL (e.g., `https://wus2.codesigning.azure.net/`) |
|
||||||
|
| `AZURE_SIGNING_ACCOUNT` | Artifact Signing account name |
|
||||||
|
| `AZURE_CERT_PROFILE` | Certificate profile name |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## macOS — Apple Developer Code Signing + Notarization
|
||||||
|
|
||||||
|
**Cost**: $99/year (Apple Developer Program)
|
||||||
|
|
||||||
|
### 1. Enroll in the Apple Developer Program
|
||||||
|
|
||||||
|
Sign up at https://developer.apple.com/programs/
|
||||||
|
|
||||||
|
### 2. Create a Developer ID Certificate
|
||||||
|
|
||||||
|
1. Open **Xcode** > **Settings** > **Accounts** > select your team > **Manage Certificates**
|
||||||
|
2. Click **+** > **Developer ID Application**
|
||||||
|
3. Or create via the Apple Developer portal: **Certificates, Identifiers & Profiles** > **Certificates** > **+** > **Developer ID Application**
|
||||||
|
|
||||||
|
### 3. Export the Certificate as .p12
|
||||||
|
|
||||||
|
1. Open **Keychain Access**
|
||||||
|
2. Find your **Developer ID Application** certificate
|
||||||
|
3. Right-click > **Export** > save as `.p12` with a password
|
||||||
|
4. Base64-encode it:
|
||||||
|
```bash
|
||||||
|
base64 -i certificate.p12 | tr -d '\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create an App Store Connect API Key
|
||||||
|
|
||||||
|
This is used for notarization (submitting the app to Apple for verification):
|
||||||
|
|
||||||
|
1. Go to https://appstoreconnect.apple.com/access/integrations/api
|
||||||
|
2. Click **Generate API Key**
|
||||||
|
3. Give it a name and **Developer** role (minimum)
|
||||||
|
4. Download the `.p8` private key file (you can only download it once)
|
||||||
|
5. Note the **Key ID** and **Issuer ID** shown on the page
|
||||||
|
|
||||||
|
### 5. Find Your Signing Identity
|
||||||
|
|
||||||
|
Your signing identity looks like:
|
||||||
|
```
|
||||||
|
Developer ID Application: Your Name (TEAMID)
|
||||||
|
```
|
||||||
|
|
||||||
|
You can find it by running:
|
||||||
|
```bash
|
||||||
|
security find-identity -v -p codesigning
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Add Gitea Secrets
|
||||||
|
|
||||||
|
| Secret Name | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| `APPLE_CERTIFICATE` | Base64-encoded .p12 certificate (from step 3) |
|
||||||
|
| `APPLE_CERTIFICATE_PASSWORD` | Password used when exporting the .p12 |
|
||||||
|
| `APPLE_SIGNING_IDENTITY` | Full identity string (e.g., `Developer ID Application: Your Name (TEAMID)`) |
|
||||||
|
| `APPLE_API_KEY` | App Store Connect API Key ID |
|
||||||
|
| `APPLE_API_ISSUER` | API issuer UUID |
|
||||||
|
| `APPLE_API_KEY_CONTENT` | Full contents of the `.p8` private key file |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verifying Signing Works
|
||||||
|
|
||||||
|
### Trigger a Build
|
||||||
|
|
||||||
|
Both build workflows use `workflow_dispatch`, so you can trigger them manually in Gitea:
|
||||||
|
|
||||||
|
1. Go to **Actions** > select the workflow > **Run workflow**
|
||||||
|
2. Enter the release tag (e.g., `v2.0.15`)
|
||||||
|
|
||||||
|
### Check macOS
|
||||||
|
|
||||||
|
After installing the `.dmg`, the app should open without any Gatekeeper warnings. You can also verify from the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codesign -dv --verbose=4 /Applications/Local\ Transcription.app
|
||||||
|
spctl --assess --type execute /Applications/Local\ Transcription.app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Windows
|
||||||
|
|
||||||
|
After running the `.msi` or `-setup.exe`, there should be no SmartScreen warning. The installer properties should show your organization name as the publisher.
|
||||||
@@ -73,8 +73,15 @@ class APIServer:
|
|||||||
original_state_cb = self.controller.on_state_changed
|
original_state_cb = self.controller.on_state_changed
|
||||||
|
|
||||||
def on_state_changed(state: str, message: str):
|
def on_state_changed(state: str, message: str):
|
||||||
|
# Isolate the upstream callback so a failure there (e.g. a
|
||||||
|
# broken stdout pipe in main_headless) cannot propagate into
|
||||||
|
# _set_state and tear down engine init / reload_engine /
|
||||||
|
# apply_settings request handling.
|
||||||
if original_state_cb:
|
if original_state_cb:
|
||||||
|
try:
|
||||||
original_state_cb(state, message)
|
original_state_cb(state, message)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._broadcast_control({"type": "state_changed", "state": state, "message": message})
|
self._broadcast_control({"type": "state_changed", "state": state, "message": message})
|
||||||
|
|
||||||
self.controller.on_state_changed = on_state_changed
|
self.controller.on_state_changed = on_state_changed
|
||||||
@@ -212,7 +219,11 @@ class APIServer:
|
|||||||
|
|
||||||
@app.put("/api/config")
|
@app.put("/api/config")
|
||||||
async def update_config(update: ConfigUpdate):
|
async def update_config(update: ConfigUpdate):
|
||||||
engine_reloaded, message = ctrl.apply_settings(update.settings)
|
import asyncio
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
engine_reloaded, message = await loop.run_in_executor(
|
||||||
|
None, ctrl.apply_settings, update.settings
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"message": message,
|
"message": message,
|
||||||
@@ -269,6 +280,7 @@ class APIServer:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
ctrl.config.set('remote.auth_token', data.get('token', ''))
|
ctrl.config.set('remote.auth_token', data.get('token', ''))
|
||||||
ctrl.config.set('remote.server_url', req.server_url)
|
ctrl.config.set('remote.server_url', req.server_url)
|
||||||
|
ctrl.config.set('remote.email', req.email)
|
||||||
return {"status": "ok", "token": data.get('token', '')}
|
return {"status": "ok", "token": data.get('token', '')}
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
raise HTTPException(status_code=resp.status_code, detail=resp.text)
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ class AppController:
|
|||||||
self.current_model_size = model
|
self.current_model_size = model
|
||||||
self.current_device_config = device_config
|
self.current_device_config = device_config
|
||||||
|
|
||||||
user_name = self.config.get('user.name', 'User')
|
|
||||||
continuous_mode = self.config.get('transcription.continuous_mode', False)
|
continuous_mode = self.config.get('transcription.continuous_mode', False)
|
||||||
|
|
||||||
if continuous_mode:
|
if continuous_mode:
|
||||||
@@ -293,7 +292,6 @@ class AppController:
|
|||||||
if remote_mode in ('managed', 'byok'):
|
if remote_mode in ('managed', 'byok'):
|
||||||
self.transcription_engine = DeepgramTranscriptionEngine(
|
self.transcription_engine = DeepgramTranscriptionEngine(
|
||||||
config=self.config,
|
config=self.config,
|
||||||
user_name=user_name,
|
|
||||||
input_device_index=audio_device,
|
input_device_index=audio_device,
|
||||||
)
|
)
|
||||||
self.transcription_engine.set_callbacks(
|
self.transcription_engine.set_callbacks(
|
||||||
@@ -343,7 +341,7 @@ class AppController:
|
|||||||
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
||||||
no_log_file=self.config.get('transcription.no_log_file', True),
|
no_log_file=self.config.get('transcription.no_log_file', True),
|
||||||
input_device_index=audio_device,
|
input_device_index=audio_device,
|
||||||
user_name=user_name,
|
app_config=self.config,
|
||||||
)
|
)
|
||||||
self.transcription_engine.set_callbacks(
|
self.transcription_engine.set_callbacks(
|
||||||
realtime_callback=self._on_realtime_transcription,
|
realtime_callback=self._on_realtime_transcription,
|
||||||
@@ -608,8 +606,17 @@ class AppController:
|
|||||||
Returns (engine_reload_needed, message).
|
Returns (engine_reload_needed, message).
|
||||||
"""
|
"""
|
||||||
if new_config:
|
if new_config:
|
||||||
for key, value in new_config.items():
|
# Flatten nested dicts into dot-notation keys so we merge
|
||||||
self.config.set(key, value)
|
# individual values instead of replacing entire sections
|
||||||
|
# (e.g. remote.mode instead of overwriting all of remote)
|
||||||
|
def _flatten(d, prefix=""):
|
||||||
|
for k, v in d.items():
|
||||||
|
full_key = f"{prefix}{k}" if not prefix else f"{prefix}.{k}"
|
||||||
|
if isinstance(v, dict):
|
||||||
|
_flatten(v, full_key)
|
||||||
|
else:
|
||||||
|
self.config.set(full_key, v)
|
||||||
|
_flatten(new_config)
|
||||||
|
|
||||||
# Update web server display settings
|
# Update web server display settings
|
||||||
if self.web_server:
|
if self.web_server:
|
||||||
@@ -682,6 +689,7 @@ class AppController:
|
|||||||
"transcription_count": len(self.transcriptions),
|
"transcription_count": len(self.transcriptions),
|
||||||
"remote_mode": remote_mode,
|
"remote_mode": remote_mode,
|
||||||
"server_sync_enabled": self.config.get('server_sync.enabled', False),
|
"server_sync_enabled": self.config.get('server_sync.enabled', False),
|
||||||
|
"is_cloud_only": self.is_cloud_only,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_audio_devices(self) -> list[dict]:
|
def get_audio_devices(self) -> list[dict]:
|
||||||
|
|||||||
@@ -75,10 +75,16 @@ def main():
|
|||||||
# Create controller and initialize
|
# Create controller and initialize
|
||||||
controller = AppController(config=config)
|
controller = AppController(config=config)
|
||||||
|
|
||||||
# Wire a state callback that prints the ready event
|
# Wire a state callback that prints state events for the parent
|
||||||
|
# process to read. Stdout writes can fail with EINVAL on Windows
|
||||||
|
# when the parent stops reading the sidecar pipe; swallow those
|
||||||
|
# so the engine state machine isn't taken down by a logging path.
|
||||||
def on_state_changed(state, message):
|
def on_state_changed(state, message):
|
||||||
event = {"event": "state", "state": state, "message": message}
|
event = {"event": "state", "state": state, "message": message}
|
||||||
|
try:
|
||||||
print(json.dumps(event), flush=True)
|
print(json.dumps(event), flush=True)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
controller.on_state_changed = on_state_changed
|
controller.on_state_changed = on_state_changed
|
||||||
|
|
||||||
|
|||||||
@@ -36,18 +36,16 @@ class DeepgramTranscriptionEngine:
|
|||||||
# Construction / configuration
|
# Construction / configuration
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def __init__(self, config, user_name: str = "User", input_device_index: Optional[int] = None):
|
def __init__(self, config, input_device_index: Optional[int] = None):
|
||||||
"""
|
"""
|
||||||
Initialise the engine from a :class:`client.config.Config` object.
|
Initialise the engine from a :class:`client.config.Config` object.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: Application ``Config`` instance.
|
config: Application ``Config`` instance.
|
||||||
user_name: Display name attached to transcriptions.
|
|
||||||
input_device_index: Index of the audio input device to use
|
input_device_index: Index of the audio input device to use
|
||||||
(``None`` for the system default).
|
(``None`` for the system default).
|
||||||
"""
|
"""
|
||||||
self.config = config
|
self.config = config
|
||||||
self.user_name = user_name
|
|
||||||
self.input_device_index = input_device_index
|
self.input_device_index = input_device_index
|
||||||
|
|
||||||
# Mode: 'managed' (proxy) or 'byok' (direct Deepgram)
|
# Mode: 'managed' (proxy) or 'byok' (direct Deepgram)
|
||||||
@@ -320,9 +318,13 @@ class DeepgramTranscriptionEngine:
|
|||||||
def _build_ws_url_and_headers(self):
|
def _build_ws_url_and_headers(self):
|
||||||
"""Return ``(url, headers)`` depending on the current mode."""
|
"""Return ``(url, headers)`` depending on the current mode."""
|
||||||
if self.mode == "managed":
|
if self.mode == "managed":
|
||||||
# Ensure the server URL uses wss:// and append the path
|
# Convert HTTP(S) URLs to WS(S) for WebSocket connection
|
||||||
url = self.server_url.rstrip("/")
|
url = self.server_url.rstrip("/")
|
||||||
if not url.startswith("ws://") and not url.startswith("wss://"):
|
if url.startswith("https://"):
|
||||||
|
url = "wss://" + url[len("https://"):]
|
||||||
|
elif url.startswith("http://"):
|
||||||
|
url = "ws://" + url[len("http://"):]
|
||||||
|
elif not url.startswith("ws://") and not url.startswith("wss://"):
|
||||||
url = f"wss://{url}"
|
url = f"wss://{url}"
|
||||||
url = f"{url}/ws/transcribe"
|
url = f"{url}/ws/transcribe"
|
||||||
return url, {}
|
return url, {}
|
||||||
@@ -450,7 +452,7 @@ class DeepgramTranscriptionEngine:
|
|||||||
text=text,
|
text=text,
|
||||||
is_final=is_final,
|
is_final=is_final,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
user_name=self.user_name,
|
user_name=self.config.get('user.name', 'User'),
|
||||||
)
|
)
|
||||||
if is_final:
|
if is_final:
|
||||||
if self.final_callback:
|
if self.final_callback:
|
||||||
@@ -501,7 +503,7 @@ class DeepgramTranscriptionEngine:
|
|||||||
text=transcript,
|
text=transcript,
|
||||||
is_final=is_final,
|
is_final=is_final,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
user_name=self.user_name,
|
user_name=self.config.get('user.name', 'User'),
|
||||||
)
|
)
|
||||||
if is_final:
|
if is_final:
|
||||||
if self.final_callback:
|
if self.final_callback:
|
||||||
@@ -532,10 +534,6 @@ class DeepgramTranscriptionEngine:
|
|||||||
pass
|
pass
|
||||||
self._ws = None
|
self._ws = None
|
||||||
|
|
||||||
def set_user_name(self, user_name: str):
|
|
||||||
"""Update the user name attached to future transcriptions."""
|
|
||||||
self.user_name = user_name
|
|
||||||
|
|
||||||
def is_recording_active(self) -> bool:
|
def is_recording_active(self) -> bool:
|
||||||
"""Return ``True`` if audio is currently being captured."""
|
"""Return ``True`` if audio is currently being captured."""
|
||||||
return self._is_recording
|
return self._is_recording
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class RealtimeTranscriptionEngine:
|
|||||||
no_log_file: bool = True,
|
no_log_file: bool = True,
|
||||||
# Audio device
|
# Audio device
|
||||||
input_device_index: Optional[int] = None,
|
input_device_index: Optional[int] = None,
|
||||||
# User name
|
# App config (for reading user.name at transcription time)
|
||||||
user_name: str = ""
|
app_config=None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize RealtimeSTT transcription engine.
|
Initialize RealtimeSTT transcription engine.
|
||||||
@@ -82,7 +82,7 @@ class RealtimeTranscriptionEngine:
|
|||||||
initial_prompt: Optional prompt to guide transcription
|
initial_prompt: Optional prompt to guide transcription
|
||||||
no_log_file: Disable RealtimeSTT logging
|
no_log_file: Disable RealtimeSTT logging
|
||||||
input_device_index: Audio input device index
|
input_device_index: Audio input device index
|
||||||
user_name: User name for transcriptions
|
app_config: App Config object for reading user.name dynamically
|
||||||
"""
|
"""
|
||||||
self.model = model
|
self.model = model
|
||||||
self.language = language
|
self.language = language
|
||||||
@@ -100,7 +100,7 @@ class RealtimeTranscriptionEngine:
|
|||||||
self.enable_realtime = enable_realtime_transcription
|
self.enable_realtime = enable_realtime_transcription
|
||||||
self.realtime_model = realtime_model
|
self.realtime_model = realtime_model
|
||||||
self.realtime_processing_pause = realtime_processing_pause
|
self.realtime_processing_pause = realtime_processing_pause
|
||||||
self.user_name = user_name
|
self.app_config = app_config
|
||||||
|
|
||||||
# Callbacks
|
# Callbacks
|
||||||
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
|
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
|
||||||
@@ -162,6 +162,11 @@ class RealtimeTranscriptionEngine:
|
|||||||
self.realtime_callback = realtime_callback
|
self.realtime_callback = realtime_callback
|
||||||
self.final_callback = final_callback
|
self.final_callback = final_callback
|
||||||
|
|
||||||
|
def _get_user_name(self) -> str:
|
||||||
|
if self.app_config:
|
||||||
|
return self.app_config.get('user.name', '')
|
||||||
|
return ''
|
||||||
|
|
||||||
def _on_realtime_transcription(self, text: str):
|
def _on_realtime_transcription(self, text: str):
|
||||||
"""Internal callback for realtime transcriptions."""
|
"""Internal callback for realtime transcriptions."""
|
||||||
if self.realtime_callback and text.strip():
|
if self.realtime_callback and text.strip():
|
||||||
@@ -169,7 +174,7 @@ class RealtimeTranscriptionEngine:
|
|||||||
text=text,
|
text=text,
|
||||||
is_final=False,
|
is_final=False,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
user_name=self.user_name
|
user_name=self._get_user_name()
|
||||||
)
|
)
|
||||||
self.realtime_callback(result)
|
self.realtime_callback(result)
|
||||||
|
|
||||||
@@ -180,7 +185,7 @@ class RealtimeTranscriptionEngine:
|
|||||||
text=text,
|
text=text,
|
||||||
is_final=True,
|
is_final=True,
|
||||||
timestamp=datetime.now(),
|
timestamp=datetime.now(),
|
||||||
user_name=self.user_name
|
user_name=self._get_user_name()
|
||||||
)
|
)
|
||||||
self.final_callback(result)
|
self.final_callback(result)
|
||||||
|
|
||||||
@@ -406,10 +411,6 @@ class RealtimeTranscriptionEngine:
|
|||||||
if self.is_recording:
|
if self.is_recording:
|
||||||
print("VAD settings updated. Restart transcription to apply changes.")
|
print("VAD settings updated. Restart transcription to apply changes.")
|
||||||
|
|
||||||
def set_user_name(self, user_name: str):
|
|
||||||
"""Set the user name for transcriptions."""
|
|
||||||
self.user_name = user_name
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"RealtimeTranscriptionEngine(model={self.model}, device={self.device}, running={self.is_recording})"
|
return f"RealtimeTranscriptionEngine(model={self.model}, device={self.device}, running={self.is_recording})"
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ web_server:
|
|||||||
|
|
||||||
remote:
|
remote:
|
||||||
mode: byok # local | managed | byok
|
mode: byok # local | managed | byok
|
||||||
server_url: "" # Proxy server URL for managed mode (e.g., wss://your-proxy.com)
|
server_url: "https://transcribe.shadowdao.com" # Proxy server URL for managed mode
|
||||||
auth_token: "" # JWT stored after login (managed mode)
|
auth_token: "" # JWT stored after login (managed mode)
|
||||||
|
email: "" # Email of the logged-in managed-mode account (for UI display)
|
||||||
byok_api_key: "" # Deepgram API key for BYOK mode
|
byok_api_key: "" # Deepgram API key for BYOK mode
|
||||||
deepgram_model: nova-2 # Deepgram model to use
|
deepgram_model: nova-2 # Deepgram model to use
|
||||||
language: en-US # Language code
|
language: en-US # Language code
|
||||||
|
|||||||
@@ -401,7 +401,6 @@ class MainWindow(QMainWindow):
|
|||||||
# Use Deepgram-based remote transcription
|
# Use Deepgram-based remote transcription
|
||||||
self.transcription_engine = DeepgramTranscriptionEngine(
|
self.transcription_engine = DeepgramTranscriptionEngine(
|
||||||
config=self.config,
|
config=self.config,
|
||||||
user_name=user_name,
|
|
||||||
input_device_index=audio_device
|
input_device_index=audio_device
|
||||||
)
|
)
|
||||||
self.transcription_engine.set_callbacks(
|
self.transcription_engine.set_callbacks(
|
||||||
@@ -431,7 +430,7 @@ class MainWindow(QMainWindow):
|
|||||||
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
||||||
no_log_file=self.config.get('transcription.no_log_file', True),
|
no_log_file=self.config.get('transcription.no_log_file', True),
|
||||||
input_device_index=audio_device,
|
input_device_index=audio_device,
|
||||||
user_name=user_name
|
app_config=self.config
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up callbacks for transcription results
|
# Set up callbacks for transcription results
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ class TranscriptionCLI:
|
|||||||
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
initial_prompt=self.config.get('transcription.initial_prompt', ''),
|
||||||
no_log_file=True,
|
no_log_file=True,
|
||||||
input_device_index=audio_device,
|
input_device_index=audio_device,
|
||||||
user_name=user_name
|
app_config=self.config
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up callbacks
|
# Set up callbacks
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "local-transcription",
|
"name": "local-transcription",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.14",
|
"version": "2.0.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "local-transcription"
|
name = "local-transcription"
|
||||||
version = "1.0.11"
|
version = "1.0.15"
|
||||||
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
|
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "local-transcription"
|
name = "local-transcription"
|
||||||
version = "2.0.14"
|
version = "2.0.20"
|
||||||
description = "Real-time speech-to-text transcription for streamers"
|
description = "Real-time speech-to-text transcription for streamers"
|
||||||
authors = ["Local Transcription Contributors"]
|
authors = ["Local Transcription Contributors"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
14
src-tauri/Entitlements.plist
Normal file
14
src-tauri/Entitlements.plist
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
8
src-tauri/Info.plist
Normal file
8
src-tauri/Info.plist
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Local Transcription needs microphone access for real-time speech-to-text transcription.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Local Transcription",
|
"productName": "Local Transcription",
|
||||||
"version": "2.0.14",
|
"version": "2.0.20",
|
||||||
"identifier": "net.anhonesthost.local-transcription",
|
"identifier": "net.anhonesthost.local-transcription",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -33,7 +33,10 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico",
|
"icons/icon.ico",
|
||||||
"icons/icon.png"
|
"icons/icon.png"
|
||||||
]
|
],
|
||||||
|
"windows": {
|
||||||
|
"digestAlgorithm": "sha256"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"shell": {
|
"shell": {
|
||||||
|
|||||||
@@ -44,9 +44,13 @@
|
|||||||
let byokApiKey = $state("");
|
let byokApiKey = $state("");
|
||||||
let managedEmail = $state("");
|
let managedEmail = $state("");
|
||||||
let managedPassword = $state("");
|
let managedPassword = $state("");
|
||||||
|
let managedLoggedIn = $state(false);
|
||||||
let autoCheckUpdates = $state(true);
|
let autoCheckUpdates = $state(true);
|
||||||
|
|
||||||
let isCloudMode = $derived(remoteMode === "managed" || remoteMode === "byok");
|
let isCloudMode = $derived(remoteMode === "managed" || remoteMode === "byok");
|
||||||
|
let isCloudOnly = $derived(
|
||||||
|
computeDevices.some(d => d.id === "cloud")
|
||||||
|
);
|
||||||
|
|
||||||
// Room creation / join state
|
// Room creation / join state
|
||||||
let shareCode = $state("");
|
let shareCode = $state("");
|
||||||
@@ -128,6 +132,8 @@
|
|||||||
remoteMode = cfg.remote.mode;
|
remoteMode = cfg.remote.mode;
|
||||||
remoteServerUrl = cfg.remote.server_url;
|
remoteServerUrl = cfg.remote.server_url;
|
||||||
byokApiKey = cfg.remote.byok_api_key ?? "";
|
byokApiKey = cfg.remote.byok_api_key ?? "";
|
||||||
|
managedEmail = cfg.remote.email ?? "";
|
||||||
|
managedLoggedIn = !!(cfg.remote.auth_token && cfg.remote.email);
|
||||||
autoCheckUpdates = cfg.updates.auto_check;
|
autoCheckUpdates = cfg.updates.auto_check;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,7 +213,7 @@
|
|||||||
},
|
},
|
||||||
remote: {
|
remote: {
|
||||||
mode: remoteMode,
|
mode: remoteMode,
|
||||||
server_url: remoteServerUrl,
|
server_url: remoteServerUrl || MANAGED_SERVER_URL,
|
||||||
byok_api_key: byokApiKey,
|
byok_api_key: byokApiKey,
|
||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
@@ -252,25 +258,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MANAGED_SERVER_URL = "https://transcribe.shadowdao.com";
|
||||||
|
|
||||||
|
let loginMessage = $state("");
|
||||||
|
|
||||||
async function handleManagedLogin() {
|
async function handleManagedLogin() {
|
||||||
|
loginMessage = "";
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/login", {
|
await backendStore.apiPost("/api/login", {
|
||||||
email: managedEmail,
|
email: managedEmail,
|
||||||
password: managedPassword,
|
password: managedPassword,
|
||||||
|
server_url: remoteServerUrl || MANAGED_SERVER_URL,
|
||||||
});
|
});
|
||||||
|
loginMessage = "Logged in successfully!";
|
||||||
|
managedPassword = "";
|
||||||
|
managedLoggedIn = true;
|
||||||
|
await configStore.fetchConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Login failed:", err);
|
console.error("Login failed:", err);
|
||||||
|
loginMessage = "Login failed. Check your email and password.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleManagedRegister() {
|
async function handleManagedLogout() {
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/register", {
|
await configStore.updateConfig({
|
||||||
email: managedEmail,
|
remote: { auth_token: "", email: "" },
|
||||||
password: managedPassword,
|
|
||||||
});
|
});
|
||||||
|
managedLoggedIn = false;
|
||||||
|
managedPassword = "";
|
||||||
|
loginMessage = "";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Register failed:", err);
|
console.error("Logout failed:", err);
|
||||||
|
loginMessage = `Error: ${err}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +378,17 @@
|
|||||||
roomCreateMessage = "Room joined! Fields have been auto-filled.";
|
roomCreateMessage = "Room joined! Fields have been auto-filled.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleShareCurrentRoom() {
|
||||||
|
const code = encodeShareCode(syncUrl, syncRoom, syncPassphrase);
|
||||||
|
shareCode = code;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
roomCreateMessage = "Share code copied to clipboard!";
|
||||||
|
} catch {
|
||||||
|
roomCreateMessage = "Share code generated. Copy it from the field below.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyShareCode() {
|
async function copyShareCode() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareCode);
|
await navigator.clipboard.writeText(shareCode);
|
||||||
@@ -450,6 +481,7 @@
|
|||||||
/>
|
/>
|
||||||
Managed Service
|
Managed Service
|
||||||
</label>
|
</label>
|
||||||
|
{#if !isCloudOnly}
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -459,6 +491,7 @@
|
|||||||
/>
|
/>
|
||||||
Local (Whisper)
|
Local (Whisper)
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if remoteMode === "byok"}
|
{#if remoteMode === "byok"}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -475,16 +508,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if remoteMode === "managed"}
|
{#if remoteMode === "managed"}
|
||||||
<div class="field">
|
|
||||||
<label for="remote-url">Server URL</label>
|
|
||||||
<input
|
|
||||||
id="remote-url"
|
|
||||||
type="url"
|
|
||||||
bind:value={remoteServerUrl}
|
|
||||||
placeholder="wss://your-proxy.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="managed-auth">
|
<div class="managed-auth">
|
||||||
|
{#if managedLoggedIn}
|
||||||
|
<p style="font-size: 13px; margin: 0 0 8px;">
|
||||||
|
<span style="color: var(--accent-green, #4CAF50);">✓ Logged in</span>
|
||||||
|
as <strong>{managedEmail}</strong>
|
||||||
|
</p>
|
||||||
|
<div class="auth-buttons">
|
||||||
|
<button onclick={handleManagedLogout}>Log out</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="managed-email">Email</label>
|
<label for="managed-email">Email</label>
|
||||||
<input
|
<input
|
||||||
@@ -504,8 +537,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="auth-buttons">
|
<div class="auth-buttons">
|
||||||
<button onclick={handleManagedLogin}>Login</button>
|
<button onclick={handleManagedLogin}>Login</button>
|
||||||
<button onclick={handleManagedRegister}>Register</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p style="font-size: 11px; color: var(--text-muted); margin-top: 8px;">
|
||||||
|
Don't have an account? <a href="https://transcribe.shadowdao.com/register.html" target="_blank" rel="noopener" style="color: var(--accent-blue);">Sign up here</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if loginMessage}
|
||||||
|
<p style="font-size: 12px; margin-top: 6px; color: {loginMessage.startsWith('Logged') ? 'var(--accent-green, #4CAF50)' : 'var(--accent-red, #f44336)'};">
|
||||||
|
{loginMessage}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
@@ -826,6 +867,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if syncEnabled}
|
{#if syncEnabled}
|
||||||
<div class="room-actions">
|
<div class="room-actions">
|
||||||
|
<div class="room-buttons-row">
|
||||||
<button
|
<button
|
||||||
onclick={handleCreateRoom}
|
onclick={handleCreateRoom}
|
||||||
disabled={roomCreating}
|
disabled={roomCreating}
|
||||||
@@ -833,6 +875,14 @@
|
|||||||
>
|
>
|
||||||
{roomCreating ? "Creating..." : "Create Room"}
|
{roomCreating ? "Creating..." : "Create Room"}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleShareCurrentRoom}
|
||||||
|
disabled={!syncUrl.trim() || !syncRoom.trim() || !syncPassphrase.trim()}
|
||||||
|
class="secondary"
|
||||||
|
>
|
||||||
|
Share Current Room
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="join-row">
|
<div class="join-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -1086,6 +1136,11 @@
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-buttons-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.join-row {
|
.join-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface BackendState {
|
|||||||
wsConnection: WebSocket | null;
|
wsConnection: WebSocket | null;
|
||||||
version: string;
|
version: string;
|
||||||
lastError: string;
|
lastError: string;
|
||||||
|
isCloudOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = $state<BackendState>({
|
let state = $state<BackendState>({
|
||||||
@@ -30,6 +31,7 @@ let state = $state<BackendState>({
|
|||||||
wsConnection: null,
|
wsConnection: null,
|
||||||
version: "1.4.0",
|
version: "1.4.0",
|
||||||
lastError: "",
|
lastError: "",
|
||||||
|
isCloudOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -72,6 +74,9 @@ async function pollStatus() {
|
|||||||
if (data.version) {
|
if (data.version) {
|
||||||
state.version = data.version;
|
state.version = data.version;
|
||||||
}
|
}
|
||||||
|
if (data.is_cloud_only !== undefined) {
|
||||||
|
state.isCloudOnly = data.is_cloud_only;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// API not ready yet, will retry
|
// API not ready yet, will retry
|
||||||
@@ -285,6 +290,9 @@ export const backendStore = {
|
|||||||
get lastError() {
|
get lastError() {
|
||||||
return state.lastError;
|
return state.lastError;
|
||||||
},
|
},
|
||||||
|
get isCloudOnly() {
|
||||||
|
return state.isCloudOnly;
|
||||||
|
},
|
||||||
get apiBaseUrl() {
|
get apiBaseUrl() {
|
||||||
return `http://localhost:${state.port}`;
|
return `http://localhost:${state.port}`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export interface AppConfig {
|
|||||||
mode: string;
|
mode: string;
|
||||||
server_url: string;
|
server_url: string;
|
||||||
auth_token: string;
|
auth_token: string;
|
||||||
|
email: string;
|
||||||
byok_api_key: string;
|
byok_api_key: string;
|
||||||
deepgram_model: string;
|
deepgram_model: string;
|
||||||
language: string;
|
language: string;
|
||||||
@@ -131,6 +132,7 @@ function getDefaultConfig(): AppConfig {
|
|||||||
mode: "byok",
|
mode: "byok",
|
||||||
server_url: "",
|
server_url: "",
|
||||||
auth_token: "",
|
auth_token: "",
|
||||||
|
email: "",
|
||||||
byok_api_key: "",
|
byok_api_key: "",
|
||||||
deepgram_model: "nova-2",
|
deepgram_model: "nova-2",
|
||||||
language: "en-US",
|
language: "en-US",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Version information for Local Transcription."""
|
"""Version information for Local Transcription."""
|
||||||
|
|
||||||
__version__ = "2.0.14"
|
__version__ = "2.0.20"
|
||||||
__version_info__ = (2, 0, 14)
|
__version_info__ = (2, 0, 20)
|
||||||
|
|
||||||
# Version history:
|
# Version history:
|
||||||
# 1.4.0 - Auto-update feature:
|
# 1.4.0 - Auto-update feature:
|
||||||
|
|||||||
Reference in New Issue
Block a user