Compare commits

...
3 Commits
Author SHA1 Message Date
DeveloperandClaude Opus 4.6 25d2a55efb Add Gitea CI/CD workflows for cross-platform builds
Two workflows adapted from voice-to-notes:

- release.yml: Builds the Tauri app shell (.deb/.rpm for Linux, .msi
  for Windows, .dmg for macOS) on push to main. Auto-bumps version,
  creates Gitea release, uploads platform binaries.

- build-sidecar.yml: Builds the headless Python backend sidecar via
  PyInstaller when client/server/backend code changes. Produces CUDA
  and CPU variants for Linux/Windows, CPU-only for macOS. Uses the new
  local-transcription-headless.spec (no PySide6 dependencies).

Also adds local-transcription-headless.spec — a simplified PyInstaller
config for the headless backend that excludes all Qt/PySide6 imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:44:34 -07:00
DeveloperandClaude Opus 4.6 af534bf768 Add Tauri v2 + Svelte 5 frontend and headless Python backend
Scaffold the cross-platform rewrite from PySide6/Qt to Tauri + Svelte,
following the same architecture as voice-to-notes. The Python backend
runs headless as a sidecar, with a FastAPI control API that the Svelte
frontend connects to via REST and WebSocket.

New files:
- backend/app_controller.py: Headless orchestration (extracted from MainWindow)
- backend/api_server.py: FastAPI control endpoints + /ws/control WebSocket
- backend/main_headless.py: Headless entry point for sidecar mode
- src-tauri/: Tauri v2 Rust shell with sidecar and dialog plugins
- src/: Svelte 5 frontend (App, Settings, Controls, TranscriptionDisplay)
- src/lib/stores/: Reactive stores for backend connection, config, transcriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 10:20:25 -07:00
DeveloperandClaude Opus 4.6 9ff883e2e3 Phase 6: Add Deepgram remote transcription (managed + BYOK modes)
New files:
- client/deepgram_transcription.py — DeepgramTranscriptionEngine with
  managed mode (proxy) and BYOK mode (direct Deepgram). Sends raw binary
  PCM audio over WebSocket, handles both proxy and Deepgram response formats.

Modified files:
- config/default_config.yaml — Replace remote_processing with new remote
  section (mode, server_url, auth_token, byok_api_key, deepgram_model, language)
- client/config.py — Add migration from old remote_processing config
- gui/settings_dialog_qt.py — Replace Remote Processing group with
  Transcription Mode section (Local/Managed/BYOK radio buttons, login/register
  dialogs, balance display, model selector)
- gui/main_window_qt.py — Select engine based on remote.mode config,
  add error and credits_low handlers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:45:30 -07:00
40 changed files with 16409 additions and 74 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(python3:*)",
"Bash(node --check:*)",
"Bash(ls:*)"
]
}
}
+414
View File
@@ -0,0 +1,414 @@
name: Build Sidecars
on:
push:
branches: [main]
paths:
- 'client/**'
- 'server/**'
- 'backend/**'
- 'pyproject.toml'
- 'local-transcription-headless.spec'
workflow_dispatch:
jobs:
bump-sidecar-version:
name: Bump sidecar version and tag
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.version }}
tag: ${{ steps.bump.outputs.tag }}
has_changes: ${{ steps.check_changes.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check for backend changes
id: check_changes
run: |
# If triggered by workflow_dispatch, always build
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
exit 0
fi
# Check if relevant files changed in this commit
CHANGED=$(git diff --name-only HEAD~1 HEAD -- client/ server/ backend/ pyproject.toml local-transcription-headless.spec 2>/dev/null || echo "")
if [ -n "$CHANGED" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Backend changes detected: $CHANGED"
else
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No backend changes detected, skipping sidecar build"
fi
- name: Configure git
if: steps.check_changes.outputs.has_changes == 'true'
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
- name: Bump sidecar patch version
if: steps.check_changes.outputs.has_changes == 'true'
id: bump
run: |
# Read current version from pyproject.toml
CURRENT=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "Current sidecar version: ${CURRENT}"
# Increment patch number
MAJOR=$(echo "${CURRENT}" | cut -d. -f1)
MINOR=$(echo "${CURRENT}" | cut -d. -f2)
PATCH=$(echo "${CURRENT}" | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New sidecar version: ${NEW_VERSION}"
# Update pyproject.toml
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
# Update version.py
sed -i "s/__version__ = \"${CURRENT}\"/__version__ = \"${NEW_VERSION}\"/" version.py
sed -i "s/__version_info__ = .*/__version_info__ = (${MAJOR}, ${MINOR}, ${NEW_PATCH})/" version.py
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=sidecar-v${NEW_VERSION}" >> $GITHUB_OUTPUT
- name: Commit and tag
if: steps.check_changes.outputs.has_changes == 'true'
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
NEW_VERSION="${{ steps.bump.outputs.version }}"
TAG="${{ steps.bump.outputs.tag }}"
git add pyproject.toml version.py
git commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]"
git tag "${TAG}"
REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|")
git pull --rebase "${REMOTE_URL}" main || true
git push "${REMOTE_URL}" HEAD:main
git push "${REMOTE_URL}" "${TAG}"
- name: Create Gitea release
if: steps.check_changes.outputs.has_changes == 'true'
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.bump.outputs.tag }}"
VERSION="${{ steps.bump.outputs.version }}"
RELEASE_NAME="Sidecar v${VERSION}"
curl -s -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated sidecar build.\", \"draft\": false, \"prerelease\": false}" \
"${REPO_API}/releases"
echo "Created release: ${RELEASE_NAME}"
# ── Linux sidecar (CUDA + CPU) ──
build-sidecar-linux:
name: Build Sidecar (Linux)
needs: bump-sidecar-version
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
runs-on: ubuntu-latest
env:
PYTHON_VERSION: "3.11"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
- name: Install uv
run: |
if command -v uv &> /dev/null; then
echo "uv already installed: $(uv --version)"
else
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
fi
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y portaudio19-dev
- name: Build sidecar (CUDA)
run: |
uv sync
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CUDA)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cuda.zip .
- name: Build sidecar (CPU)
run: |
rm -rf dist/local-transcription-backend build/
# Install CPU-only PyTorch
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cpu.zip .
- name: Upload to sidecar release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ needs.bump-sidecar-version.outputs.tag }}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
exit 1
fi
for file in sidecar-*.zip; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done
# ── Windows sidecar (CUDA + CPU) ──
build-sidecar-windows:
name: Build Sidecar (Windows)
needs: bump-sidecar-version
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
runs-on: windows-latest
env:
PYTHON_VERSION: "3.11"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
- name: Install uv
shell: powershell
run: |
if (Get-Command uv -ErrorAction SilentlyContinue) {
Write-Host "uv already installed: $(uv --version)"
} else {
irm https://astral.sh/uv/install.ps1 | iex
echo "$env:USERPROFILE\.local\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
}
- name: Set up Python
shell: powershell
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install 7-Zip
shell: powershell
run: |
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
choco install 7zip -y
}
- name: Build sidecar (CUDA)
shell: powershell
run: |
uv sync
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CUDA)
shell: powershell
run: |
7z a -tzip -mx=5 sidecar-windows-x86_64-cuda.zip .\dist\local-transcription-backend\*
- name: Build sidecar (CPU)
shell: powershell
run: |
Remove-Item -Recurse -Force dist\local-transcription-backend, build -ErrorAction SilentlyContinue
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
shell: powershell
run: |
7z a -tzip -mx=5 sidecar-windows-x86_64-cpu.zip .\dist\local-transcription-backend\*
- name: Upload to sidecar release
shell: powershell
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = "${{ needs.bump-sidecar-version.outputs.tag }}"
Write-Host "Waiting for sidecar release ${TAG} to be available..."
$RELEASE_ID = $null
for ($i = 1; $i -le 30; $i++) {
try {
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
if ($RELEASE_ID) {
Write-Host "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
break
}
} catch {}
Write-Host "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
Start-Sleep -Seconds 10
}
if (-not $RELEASE_ID) {
Write-Host "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
exit 1
}
Get-ChildItem -Path . -Filter "sidecar-*.zip" | ForEach-Object {
$filename = $_.Name
$encodedName = [System.Uri]::EscapeDataString($filename)
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host "Uploading ${filename} (${size} MB)..."
try {
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $filename }
if ($existing) {
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
}
} catch {}
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
$result = curl.exe --fail --silent --show-error `
-X POST `
-H "Authorization: token $env:BUILD_TOKEN" `
-H "Content-Type: application/octet-stream" `
-T "$($_.FullName)" `
"$uploadUrl" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Upload successful: ${filename}"
} else {
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
}
}
# ── macOS sidecar (CPU only — no CUDA on macOS) ──
build-sidecar-macos:
name: Build Sidecar (macOS)
needs: bump-sidecar-version
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
runs-on: macos-latest
env:
PYTHON_VERSION: "3.11"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
- name: Install uv
run: |
if command -v uv &> /dev/null; then
echo "uv already installed: $(uv --version)"
else
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
fi
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: brew install portaudio
- name: Build sidecar (CPU)
run: |
# Install CPU-only PyTorch for macOS (MPS support included in default torch)
uv sync
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-macos-aarch64-cpu.zip .
- name: Upload to sidecar release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ needs.bump-sidecar-version.outputs.tag }}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
exit 1
fi
for file in sidecar-*.zip; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done
+300
View File
@@ -0,0 +1,300 @@
name: Release
on:
push:
branches: [main]
jobs:
bump-version:
name: Bump version and tag
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.bump.outputs.new_version }}
tag: ${{ steps.bump.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
- name: Bump patch version
id: bump
run: |
# Read current version from package.json
CURRENT=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
echo "Current version: ${CURRENT}"
# Increment patch number
MAJOR=$(echo "${CURRENT}" | cut -d. -f1)
MINOR=$(echo "${CURRENT}" | cut -d. -f2)
PATCH=$(echo "${CURRENT}" | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New version: ${NEW_VERSION}"
# Update package.json
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json
# Update src-tauri/tauri.conf.json
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" src-tauri/tauri.conf.json
# Update src-tauri/Cargo.toml
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" src-tauri/Cargo.toml
# Update version.py
sed -i "s/__version__ = \"${CURRENT}\"/__version__ = \"${NEW_VERSION}\"/" version.py
sed -i "s/__version_info__ = .*/__version_info__ = (${MAJOR}, ${MINOR}, ${NEW_PATCH})/" version.py
echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=v${NEW_VERSION}" >> $GITHUB_OUTPUT
- name: Commit and tag
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
NEW_VERSION="${{ steps.bump.outputs.new_version }}"
git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml version.py
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
git tag "v${NEW_VERSION}"
REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|")
git pull --rebase "${REMOTE_URL}" main || true
git push "${REMOTE_URL}" HEAD:main
git push "${REMOTE_URL}" "v${NEW_VERSION}"
- name: Create Gitea release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.bump.outputs.tag }}"
RELEASE_NAME="Local Transcription ${TAG}"
curl -s -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated build.\", \"draft\": false, \"prerelease\": false}" \
"${REPO_API}/releases"
echo "Created release: ${RELEASE_NAME}"
# ── Platform builds (run after version bump) ──
build-linux:
name: Build App (Linux)
needs: bump-version
runs-on: ubuntu-latest
env:
NODE_VERSION: "20"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-version.outputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils rpm
- name: Install npm dependencies
run: npm ci
- name: Build Tauri app
run: npm run tauri build
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ needs.bump-version.outputs.tag }}"
echo "Release tag: ${TAG}"
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find release for tag ${TAG}."
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
find src-tauri/target/release/bundle -type f \( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) | while IFS= read -r file; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done
build-windows:
name: Build App (Windows)
needs: bump-version
runs-on: windows-latest
env:
NODE_VERSION: "20"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-version.outputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
shell: powershell
run: |
if (Get-Command rustup -ErrorAction SilentlyContinue) {
rustup default stable
} else {
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
}
- name: Install npm dependencies
shell: powershell
run: npm ci
- name: Build Tauri app
shell: powershell
run: npm run tauri build
- name: Upload to release
shell: powershell
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = "${{ needs.bump-version.outputs.tag }}"
Write-Host "Release tag: ${TAG}"
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
Write-Host "Release ID: ${RELEASE_ID}"
Get-ChildItem -Path src-tauri\target\release\bundle -Recurse -Include *.msi,*-setup.exe | ForEach-Object {
$filename = $_.Name
$encodedName = [System.Uri]::EscapeDataString($filename)
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host "Uploading ${filename} (${size} MB)..."
try {
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $filename }
if ($existing) {
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
}
} catch {}
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
$result = curl.exe --fail --silent --show-error `
-X POST `
-H "Authorization: token $env:BUILD_TOKEN" `
-H "Content-Type: application/octet-stream" `
-T "$($_.FullName)" `
"$uploadUrl" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Upload successful: ${filename}"
} else {
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
}
}
build-macos:
name: Build App (macOS)
needs: bump-version
runs-on: macos-latest
env:
NODE_VERSION: "20"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-version.outputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install system dependencies
run: brew install --quiet create-dmg || true
- name: Install npm dependencies
run: npm ci
- name: Build Tauri app
run: npm run tauri build
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ needs.bump-version.outputs.tag }}"
echo "Release tag: ${TAG}"
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find release for tag ${TAG}."
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
find src-tauri/target/release/bundle -type f -name "*.dmg" | while IFS= read -r file; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done
+9
View File
@@ -54,3 +54,12 @@ models/
# PyInstaller # PyInstaller
*.spec.lock *.spec.lock
# Node.js
node_modules/
# Vite / Svelte build output
dist/
# Tauri
src-tauri/target/
+574
View File
@@ -0,0 +1,574 @@
# Deepgram Proxy Service — Build Plan
## Project Overview
Build a standalone hosted service that acts as a Deepgram proxy for the Local Transcription
desktop app. Users can either provide their own Deepgram API key (BYOK) or use the managed
service with prepaid credits purchased via Stripe.
This is a **separate repository** from `local-transcription`. The desktop app will be updated
in a second phase to support both modes.
---
## Repository Structure
```
transcription-proxy/
├── src/
│ ├── server.js # Express app entry point
│ ├── config.js # Environment config loader
│ ├── db/
│ │ ├── index.js # node-postgres pool setup
│ │ └── migrations/ # SQL migration files (numbered)
│ │ ├── 001_users.sql
│ │ ├── 002_credits.sql
│ │ ├── 003_sessions.sql
│ │ └── 004_usage_ledger.sql
│ ├── middleware/
│ │ ├── auth.js # JWT verification middleware
│ │ └── rateLimit.js # Per-user rate limiting
│ ├── routes/
│ │ ├── auth.js # POST /auth/register, /auth/login, /auth/refresh
│ │ ├── billing.js # POST /billing/checkout, GET /billing/balance
│ │ └── account.js # GET /account/me, GET /account/usage
│ ├── websocket/
│ │ └── proxy.js # WebSocket proxy handler (core feature)
│ └── webhooks/
│ └── stripe.js # POST /webhooks/stripe
├── web/ # Simple frontend dashboard
│ ├── index.html # Landing / login page
│ ├── dashboard.html # Balance, usage history, buy credits
│ └── assets/
│ ├── app.js
│ └── style.css
├── .env.example
├── package.json
├── docker-compose.yml # Postgres + app for local dev
└── CLAUDE.md # This file (after renaming)
```
---
## Technology Stack
- **Runtime**: Node.js 20+
- **Framework**: Express 4
- **WebSocket**: `ws` library (not socket.io — keep it lean)
- **Database**: PostgreSQL 15+ via `pg` (node-postgres)
- **Auth**: JWT via `jsonwebtoken`, passwords hashed with `bcrypt`
- **Payments**: Stripe Node SDK (`stripe`)
- **Environment**: `dotenv`
- **Dev tooling**: `nodemon` for dev, no TypeScript (keep it simple)
---
## Database Schema
Run migrations in order. Use a simple `schema_migrations` table to track applied migrations.
### 001_users.sql
```sql
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
stripe_customer_id TEXT UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
### 002_credits.sql
```sql
CREATE TABLE credit_balance (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
seconds_remaining INTEGER NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
### 003_sessions.sql
```sql
CREATE TABLE transcription_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
mode TEXT NOT NULL CHECK (mode IN ('managed', 'byok')),
started_at TIMESTAMPTZ DEFAULT NOW(),
ended_at TIMESTAMPTZ,
seconds_used INTEGER NOT NULL DEFAULT 0,
deepgram_model TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'completed', 'terminated'))
);
CREATE INDEX idx_sessions_user_id ON transcription_sessions(user_id);
CREATE INDEX idx_sessions_started_at ON transcription_sessions(started_at);
```
### 004_usage_ledger.sql
```sql
CREATE TABLE usage_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
session_id UUID REFERENCES transcription_sessions(id),
recorded_at TIMESTAMPTZ DEFAULT NOW(),
seconds INTEGER NOT NULL,
description TEXT -- e.g. 'session_usage', 'credit_purchase', 'manual_adjustment'
);
CREATE INDEX idx_ledger_user_id ON usage_ledger(user_id);
```
---
## Environment Variables (.env.example)
```env
# Server
PORT=3000
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/transcription_proxy
# Auth
JWT_SECRET=changeme_use_long_random_string
JWT_EXPIRY=7d
# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Deepgram
DEEPGRAM_API_KEY=your_deepgram_key_here
# Pricing (seconds per dollar — adjust for your margin)
# Default: 1000 seconds per $1 = $0.006/min managed cost covered + margin
CREDITS_PER_DOLLAR=1000
```
---
## Phase 1 — Core Server & Auth
### Goals
- Working Express app with Postgres connection
- Migration runner
- User registration and login
- JWT middleware
### Tasks
1. **Scaffold project**
- `npm init`, install dependencies: `express ws pg jsonwebtoken bcrypt stripe dotenv`
- Dev dependencies: `nodemon`
- Add `start` and `dev` scripts to package.json
2. **Database connection** (`src/db/index.js`)
- Export a `pg.Pool` instance using `DATABASE_URL`
- Export a `migrate()` function that reads `src/db/migrations/*.sql` in order,
checks `schema_migrations` table, and applies unapplied ones
- Call `migrate()` on server startup before listening
3. **Auth routes** (`src/routes/auth.js`)
- `POST /auth/register` — validate email/password, hash password with bcrypt (cost 12),
insert user, insert empty credit_balance row, return JWT
- `POST /auth/login` — verify credentials, return JWT + refresh token
- `POST /auth/refresh` — validate refresh token, return new JWT
- Passwords: minimum 8 characters, validate email format
4. **JWT middleware** (`src/middleware/auth.js`)
- Verify `Authorization: Bearer <token>` header
- Attach `req.user = { id, email }` on success
- Return 401 on failure
- Export as `requireAuth` middleware
5. **Basic health check**
- `GET /health` returns `{ status: 'ok', db: 'connected' }`
---
## Phase 2 — Billing & Credits
### Goals
- Stripe Checkout session creation for credit purchases
- Webhook handler to fulfill purchases
- Balance endpoint
### Payment Methods
Use **Stripe Dynamic Payment Methods** — do NOT hardcode `payment_method_types` in the
Checkout Session. Instead, leave it unset and manage everything from the Stripe Dashboard.
Enable the following in the Stripe Dashboard under Settings → Payment Methods:
- **Cards** (Visa, Mastercard, Amex, Discover) — on by default
- **PayPal** — enable manually
- **Apple Pay** — on by default, shows automatically on Safari/iOS
- **Google Pay** — enable manually (one toggle)
- **Cash App Pay** — enable manually (popular with streaming audiences)
- **Link** — Stripe's saved payment network, on by default
Stripe will automatically show the most relevant methods to each user based on their
location and device. No code changes are needed to add or remove methods in future —
it's all dashboard config.
### Credit Packages
Define these as constants in `src/config.js`:
```javascript
CREDIT_PACKAGES: [
{ id: 'pack_500', label: '500 minutes', seconds: 30000, price_cents: 300 },
{ id: 'pack_1200', label: '1200 minutes', seconds: 72000, price_cents: 600 },
{ id: 'pack_3000', label: '3000 minutes', seconds: 180000, price_cents: 1200 },
]
```
Adjust pricing to cover Deepgram costs ($0.006/min = $0.0001/sec) plus margin and
Stripe fees (~2.9% + $0.30).
### Tasks
1. **Stripe customer creation**
- On user registration, create a Stripe customer and store `stripe_customer_id`
- Do this asynchronously (don't block registration response)
2. **Billing routes** (`src/routes/billing.js`)
- `GET /billing/packages` — return credit package list (no auth required)
- `POST /billing/checkout` — requires auth, accepts `{ package_id }`,
creates Stripe Checkout Session using dynamic payment methods (do NOT pass
`payment_method_types` — omitting it enables dynamic methods automatically),
include `payment_intent_data.metadata` containing `user_id` and `package_id`,
returns `{ checkout_url }`
- `GET /billing/balance` — requires auth, returns `{ seconds_remaining, minutes_remaining }`
3. **Stripe webhook** (`src/webhooks/stripe.js`)
- Mount at `POST /webhooks/stripe` with raw body (use `express.raw()` for this route only)
- Verify signature with `stripe.webhooks.constructEvent()`
- Handle `checkout.session.completed`:
- Extract `user_id` and `package_id` from metadata
- Add seconds to `credit_balance`
- Insert row into `usage_ledger` with description `'credit_purchase'`
- Handle `payment_intent.payment_failed`: log it (no action needed for prepaid)
4. **Success/cancel pages**
- Stripe Checkout redirects to `GET /billing/success?session_id=...` and `/billing/cancel`
- These can be simple HTML responses or redirects to the web dashboard
---
## Phase 3 — WebSocket Proxy (Core Feature)
This is the most critical component. The proxy sits between the desktop client and Deepgram,
forwarding audio while tracking usage in real time.
### Connection Flow
```
Client connects → validate JWT → check credit balance → open Deepgram upstream
↓
Audio chunks arrive → forward to Deepgram → record usage every 5 seconds
↓
Transcription arrives from Deepgram → forward to client
↓
Client disconnects (or credits exhausted) → close upstream → finalize session
```
### WebSocket Protocol
**Client connects to**: `wss://your-domain/ws/transcribe`
**Client sends as first message** (JSON):
```json
{
"type": "auth",
"token": "<JWT>",
"config": {
"model": "nova-2",
"language": "en-US",
"interim_results": true,
"endpointing": 300
}
}
```
**After auth success, client sends**: raw audio binary frames (PCM 16kHz mono)
**Server sends to client**:
```json
{ "type": "ready" }
{ "type": "transcript", "text": "...", "is_final": true, "confidence": 0.98 }
{ "type": "error", "code": "insufficient_credits", "message": "..." }
{ "type": "credits_low", "seconds_remaining": 300 }
{ "type": "session_end", "seconds_used": 120 }
```
### Tasks (`src/websocket/proxy.js`)
1. **Upgrade handler**
- Attach to the HTTP server using `ws.Server({ noServer: true })`
- In `server.on('upgrade', ...)`, route `/ws/transcribe` to this handler
2. **Auth handshake**
- First message must be `{ type: 'auth', token: '...' }` — received within 5 seconds
or connection is terminated
- Verify JWT, load user's credit balance from DB
- If balance is 0 or negative, send `insufficient_credits` error and close
3. **Deepgram upstream connection**
- Open a WebSocket to Deepgram's streaming API:
`wss://api.deepgram.com/v1/listen?model=nova-2&language=en-US&interim_results=true`
- Auth header: `Authorization: Token <DEEPGRAM_API_KEY>`
- Use query params from client's `config` object (whitelist allowed params)
4. **Audio forwarding**
- All binary messages from client → forward directly to Deepgram upstream
- All messages from Deepgram → parse JSON, reformat, forward to client
5. **Usage tracking**
- Create a `transcription_sessions` row on connection
- Maintain an in-memory `secondsUsed` counter per connection
- Deepgram sends `{ type: 'Results', duration: X }` in responses — use this for
accurate second counting
- Every 10 seconds (or on disconnect), write current `secondsUsed` to DB:
- Update `transcription_sessions.seconds_used`
- Decrement `credit_balance.seconds_remaining`
- Insert into `usage_ledger`
- If `seconds_remaining` hits 0: send `insufficient_credits`, close connection
6. **Cleanup on disconnect**
- Mark session as `completed`, set `ended_at`
- Do final usage flush to DB
- Close Deepgram upstream if still open
7. **Error handling**
- If Deepgram upstream closes unexpectedly, notify client and close
- If client sends malformed data, log and continue (don't crash)
---
## Phase 4 — Account Routes & Rate Limiting
### Tasks
1. **Account routes** (`src/routes/account.js`)
- `GET /account/me` — returns `{ email, credits: { seconds_remaining, minutes_remaining }, created_at }`
- `GET /account/usage` — returns last 30 days of `usage_ledger` entries grouped by day,
plus list of last 10 sessions with duration
2. **Rate limiting** (`src/middleware/rateLimit.js`)
- Use in-memory rate limiting (no Redis needed at this scale)
- Auth endpoints: max 10 requests per minute per IP
- WebSocket connections: max 2 concurrent connections per user
(store active connections in a `Map<userId, Set<ws>>`)
---
## Phase 5 — Web Dashboard
A simple, functional HTML/CSS/JS dashboard. No framework — vanilla JS is fine.
This is a developer-friendly streamer tool, not a consumer SaaS, so clean and
functional beats flashy.
### Pages
**`/` (Landing / Login)**
- Brief product description (what this is, why it exists)
- Login form and link to register
- Link to GitHub/Gitea repo
**`/dashboard` (Post-login)**
- Current credit balance (minutes remaining, prominently displayed)
- "Buy Credits" section showing the three packages with Stripe Checkout buttons
- Usage chart: last 30 days bar chart (vanilla canvas or a small CDN chart lib)
- Recent sessions table: date, duration, status
**`/register`**
- Registration form
### Implementation Notes
- Store JWT in `localStorage`, attach as `Authorization` header on API calls
- Redirect to `/` if JWT missing or expired
- Keep CSS minimal but readable — this is a utility dashboard
---
## Phase 6 — Desktop App Integration
Changes needed in the `local-transcription` Python repo.
### New file: `client/remote_transcription.py`
This module replaces `transcription_engine_realtime.py` when remote mode is active.
```python
# Pseudocode / spec for Claude Code to implement
class RemoteTranscriptionEngine:
"""
Connects to the transcription proxy WebSocket and streams audio.
Provides the same callback interface as the local engine so the
rest of the app doesn't need to change.
"""
def __init__(self, config, on_transcript_callback):
# config contains: server_url, auth_token (or byok_api_key), model
...
def start(self):
# Open WebSocket connection
# Send auth message
# Start audio capture thread (reuse existing audio_capture.py)
...
def stop(self):
# Close WebSocket gracefully
...
def _on_audio_chunk(self, audio_data):
# Called by audio_capture.py with raw PCM data
# Send as binary WebSocket frame
...
def _on_server_message(self, message):
# Parse JSON from server
# On type='transcript': call on_transcript_callback
# On type='credits_low': trigger UI warning
# On type='error': surface to user
...
```
### BYOK Mode
When user provides their own Deepgram key, connect directly to Deepgram instead of the proxy:
- Endpoint: `wss://api.deepgram.com/v1/listen?...`
- Auth: `Authorization: Token <user_key>`
- No session tracking (Deepgram handles billing directly to the user)
- Same `RemoteTranscriptionEngine` class, just different URL and auth header
### Settings Changes (`gui/settings_dialog_qt.py`)
Add a new "Transcription Mode" section:
```
Transcription Mode:
○ Local (Whisper) [existing behavior]
○ Remote - Managed [requires login]
○ Remote - BYOK [requires Deepgram API key]
[If Managed selected]:
Server URL: [____________]
[Login / Register] [View Balance: 420 min remaining]
[If BYOK selected]:
Deepgram API Key: [____________]
Model: [nova-2 ▼]
```
### Config additions (`config/default_config.yaml`)
```yaml
remote:
mode: local # local | managed | byok
server_url: "" # proxy server URL for managed mode
auth_token: "" # JWT stored after login
byok_api_key: "" # Deepgram key for BYOK mode
deepgram_model: nova-2
language: en-US
```
---
## Build & Deployment Notes
### Docker Compose (local dev)
```yaml
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_DB: transcription_proxy
POSTGRES_USER: user
POSTGRES_PASSWORD: password
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://user:password@db:5432/transcription_proxy
depends_on:
- db
volumes:
- .:/app
- /app/node_modules
volumes:
pgdata:
```
### Production Deployment
This service is a good fit for deployment on AnHonestHost WHP as a containerized app,
or on a small DigitalOcean/Linode VPS. Requirements are light:
- 512MB RAM is sufficient
- Postgres can be the same instance as other services or managed (e.g., Supabase free tier)
- Needs a public domain with SSL for WebSocket (`wss://`) to work from desktop clients
Reverse proxy config (Nginx or HAProxy) should:
- Proxy HTTP → `localhost:3000`
- Pass `Upgrade` and `Connection` headers for WebSocket support
- Set `proxy_read_timeout 3600` (sessions can be long)
---
## Implementation Order
Build and test in this sequence:
1. Project scaffold + DB connection + migrations
2. Auth (register/login/JWT) — test with curl
3. Stripe billing + webhook — test with Stripe CLI (`stripe listen`)
4. WebSocket proxy — test with a simple browser WebSocket client first
5. Usage tracking and credit decrement
6. Account/usage routes
7. Web dashboard
8. Desktop app integration (separate PR in local-transcription repo)
---
## Key Decisions & Rationale
| Decision | Choice | Reason |
|---|---|---|
| Credits model | Prepaid | No surprise charges, simpler billing, better for irregular streamer usage |
| WebSocket library | `ws` | Lightweight, no abstraction overhead, plays well with raw binary audio |
| Auth | JWT (stateless) | Desktop app holds token locally; no session store needed |
| DB driver | `node-postgres` (pg) | No ORM overhead; schema is simple enough for raw SQL |
| Migrations | Raw SQL files | No dependency on Knex/Prisma; easy to inspect and reason about |
| Rate limiting | In-memory | Redis is overkill for this scale; single-process Node is fine initially |
| Frontend | Vanilla JS | Dashboard is simple utility UI; no framework justified |
---
## What This Plan Does NOT Cover (Future Work)
- OAuth / social login
- Admin panel for managing users
- Refund / credit adjustment tooling
- Email verification
- Password reset flow
- Multi-language support beyond Deepgram's defaults
- Analytics / aggregated usage reporting
- Self-hosted Whisper inference as a third backend option
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"""Backend package for headless transcription service."""
+323
View File
@@ -0,0 +1,323 @@
"""FastAPI control API server for the headless transcription backend.
Extends the existing OBS display server with REST endpoints and a
control WebSocket channel so that a Tauri (or any other) frontend
can drive the application.
"""
import asyncio
import json
from datetime import datetime
from typing import List, Optional
from fastapi import FastAPI, WebSocket, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from backend.app_controller import AppController
# ── Request / Response Models ──────────────────────────────────────
class ConfigUpdate(BaseModel):
"""Batch config update payload. Keys use dot-notation."""
settings: dict # e.g. {"user.name": "Alice", "transcription.model": "small.en"}
class LoginRequest(BaseModel):
email: str
password: str
server_url: str
class RegisterRequest(BaseModel):
email: str
password: str
server_url: str
class SkipVersionRequest(BaseModel):
version: str
class SaveFileRequest(BaseModel):
path: str
text: str
# ── API Server ─────────────────────────────────────────────────────
class APIServer:
"""Wraps AppController with a FastAPI application exposing control endpoints."""
def __init__(self, controller: AppController):
self.controller = controller
self.control_connections: List[WebSocket] = []
self.app = FastAPI(title="Local Transcription API", version="1.0.0")
# Allow Tauri webview origin
self.app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Tauri uses tauri://localhost or https://tauri.localhost
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
self._setup_routes()
self._wire_controller_callbacks()
def _wire_controller_callbacks(self):
"""Wire AppController callbacks to broadcast over /ws/control."""
original_state_cb = self.controller.on_state_changed
def on_state_changed(state: str, message: str):
if original_state_cb:
original_state_cb(state, message)
self._broadcast_control({"type": "state_changed", "state": state, "message": message})
self.controller.on_state_changed = on_state_changed
def on_transcription(data: dict):
self._broadcast_control({"type": "transcription", **data})
self.controller.on_transcription = on_transcription
def on_preview(data: dict):
self._broadcast_control({"type": "preview", **data})
self.controller.on_preview = on_preview
def on_error(msg: str):
self._broadcast_control({"type": "error", "message": msg})
self.controller.on_error = on_error
def on_credits_low(seconds: int):
self._broadcast_control({"type": "credits_low", "seconds_remaining": seconds})
self.controller.on_credits_low = on_credits_low
def _broadcast_control(self, data: dict):
"""Send a message to all connected /ws/control clients."""
if not self.control_connections:
return
message = json.dumps(data)
disconnected = []
for ws in self.control_connections:
try:
asyncio.run_coroutine_threadsafe(
ws.send_text(message),
asyncio.get_event_loop(),
)
except Exception:
disconnected.append(ws)
for ws in disconnected:
self.control_connections.remove(ws)
def _setup_routes(self):
"""Register all API routes."""
app = self.app
ctrl = self.controller
# ── Status ─────────────────────────────────────────────
@app.get("/api/status")
async def get_status():
return ctrl.get_status()
@app.get("/api/version")
async def get_version():
from version import __version__
return {"version": __version__}
# ── Transcription Control ──────────────────────────────
@app.post("/api/start")
async def start_transcription():
success, message = ctrl.start_transcription()
if not success:
raise HTTPException(status_code=400, detail=message)
return {"status": "ok", "message": message}
@app.post("/api/stop")
async def stop_transcription():
success, message = ctrl.stop_transcription()
if not success:
raise HTTPException(status_code=400, detail=message)
return {"status": "ok", "message": message}
@app.post("/api/clear")
async def clear_transcriptions():
count = ctrl.clear_transcriptions()
return {"status": "ok", "cleared": count}
@app.get("/api/transcriptions")
async def get_transcriptions():
show_timestamps = ctrl.config.get('display.show_timestamps', True)
return {
"count": len(ctrl.transcriptions),
"text": ctrl.get_transcriptions_text(include_timestamps=show_timestamps),
"items": [
{
"text": r.text,
"user_name": r.user_name,
"timestamp": r.timestamp.strftime("%H:%M:%S") if r.timestamp else None,
}
for r in ctrl.transcriptions
],
}
@app.post("/api/save-file")
async def save_file(req: SaveFileRequest):
"""Save text to a file (used by Tauri frontend after dialog)."""
from pathlib import Path
try:
Path(req.path).write_text(req.text, encoding="utf-8")
return {"status": "ok", "path": req.path}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ── Configuration ──────────────────────────────────────
@app.get("/api/config")
async def get_config():
return ctrl.config.config
@app.put("/api/config")
async def update_config(update: ConfigUpdate):
engine_reloaded, message = ctrl.apply_settings(update.settings)
return {
"status": "ok",
"message": message,
"engine_reloaded": engine_reloaded,
}
# ── Devices ────────────────────────────────────────────
@app.get("/api/audio-devices")
async def get_audio_devices():
return {"devices": ctrl.get_audio_devices()}
@app.get("/api/compute-devices")
async def get_compute_devices():
return {"devices": ctrl.get_compute_devices()}
# ── Engine ─────────────────────────────────────────────
@app.post("/api/reload-engine")
async def reload_engine():
success, message = ctrl.reload_engine()
if not success:
raise HTTPException(status_code=500, detail=message)
return {"status": "ok", "message": message}
# ── Updates ────────────────────────────────────────────
@app.get("/api/check-update")
async def check_update():
return ctrl.check_for_updates()
@app.post("/api/skip-version")
async def skip_version(req: SkipVersionRequest):
ctrl.skip_version(req.version)
return {"status": "ok"}
# ── Managed Mode Auth Proxy ────────────────────────────
@app.post("/api/login")
async def login(req: LoginRequest):
"""Proxy login to the transcription proxy server."""
import requests as http_requests
try:
resp = http_requests.post(
f"{req.server_url}/api/auth/login",
json={"email": req.email, "password": req.password},
timeout=10,
)
if resp.status_code == 200:
data = resp.json()
ctrl.config.set('remote.auth_token', data.get('token', ''))
ctrl.config.set('remote.server_url', req.server_url)
return {"status": "ok", "token": data.get('token', '')}
else:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
except http_requests.RequestException as e:
raise HTTPException(status_code=502, detail=str(e))
@app.post("/api/register")
async def register(req: RegisterRequest):
"""Proxy registration to the transcription proxy server."""
import requests as http_requests
try:
resp = http_requests.post(
f"{req.server_url}/api/auth/register",
json={"email": req.email, "password": req.password},
timeout=10,
)
if resp.status_code in (200, 201):
return {"status": "ok", "data": resp.json()}
else:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
except http_requests.RequestException as e:
raise HTTPException(status_code=502, detail=str(e))
@app.get("/api/balance")
async def get_balance():
"""Proxy balance check to the transcription proxy server."""
import requests as http_requests
server_url = ctrl.config.get('remote.server_url', '')
token = ctrl.config.get('remote.auth_token', '')
if not server_url or not token:
raise HTTPException(status_code=400, detail="Not logged in to managed service")
try:
resp = http_requests.get(
f"{server_url}/api/billing/balance",
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if resp.status_code == 200:
return resp.json()
else:
raise HTTPException(status_code=resp.status_code, detail=resp.text)
except http_requests.RequestException as e:
raise HTTPException(status_code=502, detail=str(e))
# ── Control WebSocket ──────────────────────────────────
@app.websocket("/ws/control")
async def websocket_control(websocket: WebSocket):
"""WebSocket channel for real-time state and transcription push."""
await websocket.accept()
self.control_connections.append(websocket)
# Send current status on connect
try:
await websocket.send_json({
"type": "state_changed",
"state": ctrl.state,
"message": "Connected",
})
except Exception:
pass
try:
while True:
# Keep alive -- client sends pings
await websocket.receive_text()
except Exception:
if websocket in self.control_connections:
self.control_connections.remove(websocket)
# ── Mount the existing OBS display routes ──────────────
# The OBS display (GET / and /ws) is handled by the
# TranscriptionWebServer which shares the same Uvicorn
# instance. We mount it as a sub-application so the
# existing OBS URLs continue to work.
if ctrl.web_server:
app.mount("/obs", ctrl.web_server.app)
+692
View File
@@ -0,0 +1,692 @@
"""Headless application controller for transcription backend.
Extracts orchestration logic from gui/main_window_qt.py into a
Qt-free class that manages engine lifecycle, web server, server sync,
and configuration -- all accessible via callbacks instead of Qt signals.
"""
import asyncio
import time
from datetime import datetime
from pathlib import Path
from threading import Thread, Lock
from typing import Callable, List, Optional
import sys
# Add project root to path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from client.config import Config
from client.device_utils import DeviceManager
from client.transcription_engine_realtime import RealtimeTranscriptionEngine, TranscriptionResult
from client.deepgram_transcription import DeepgramTranscriptionEngine
from client.server_sync import ServerSyncClient
from server.web_display import TranscriptionWebServer
from version import __version__
class AppState:
"""Enum-like class for application states."""
INITIALIZING = "initializing"
READY = "ready"
TRANSCRIBING = "transcribing"
RELOADING = "reloading"
ERROR = "error"
class WebServerThread(Thread):
"""Thread for running the web server."""
def __init__(self, web_server: TranscriptionWebServer):
super().__init__(daemon=True)
self.web_server = web_server
self.loop: Optional[asyncio.AbstractEventLoop] = None
self.error: Optional[Exception] = None
def run(self):
try:
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self.web_server.start())
except Exception as e:
self.error = e
print(f"ERROR: Web server failed to start: {e}")
class EngineInitThread(Thread):
"""Thread for initializing the transcription engine without blocking."""
def __init__(self, engine, on_complete: Callable[[bool, str], None]):
super().__init__(daemon=True)
self.engine = engine
self.on_complete = on_complete
def run(self):
try:
success = self.engine.initialize()
if success:
self.on_complete(True, "Engine initialized successfully")
else:
self.on_complete(False, "Failed to initialize engine")
except Exception as e:
self.on_complete(False, f"Error initializing engine: {e}")
class AppController:
"""Headless controller managing the transcription application lifecycle.
This replaces the orchestration logic that previously lived in MainWindow.
It manages:
- Transcription engine lifecycle (init, start, stop, reload)
- Web server for OBS display
- Server sync for multi-user mode
- Configuration
- Update checking
All state changes are communicated via callbacks, making it UI-agnostic.
"""
def __init__(self, config: Optional[Config] = None):
self.config = config or Config()
self.device_manager = DeviceManager()
# State
self._state = AppState.INITIALIZING
self._state_lock = Lock()
self.is_transcribing = False
# Engine
self.transcription_engine = None
self._engine_init_thread: Optional[EngineInitThread] = None
self.current_model_size: Optional[str] = None
self.current_device_config: Optional[str] = None
# Web server
self.web_server: Optional[TranscriptionWebServer] = None
self.web_server_thread: Optional[WebServerThread] = None
self.actual_web_port: Optional[int] = None
# Server sync
self.server_sync_client: Optional[ServerSyncClient] = None
# Transcription storage
self.transcriptions: List[TranscriptionResult] = []
# Callbacks for state notifications (set by the frontend / API server)
self.on_state_changed: Optional[Callable[[str, str], None]] = None # (state, message)
self.on_transcription: Optional[Callable[[dict], None]] = None # final transcription
self.on_preview: Optional[Callable[[dict], None]] = None # realtime preview
self.on_error: Optional[Callable[[str], None]] = None
self.on_credits_low: Optional[Callable[[int], None]] = None
@property
def state(self) -> str:
with self._state_lock:
return self._state
def _set_state(self, state: str, message: str = ""):
with self._state_lock:
self._state = state
if self.on_state_changed:
self.on_state_changed(state, message)
# ── Lifecycle ──────────────────────────────────────────────────
def initialize(self):
"""Initialize the web server and transcription engine.
Call this once at startup. Non-blocking -- engine init happens
in a background thread.
"""
self._set_state(AppState.INITIALIZING, "Starting web server...")
self._start_web_server()
self._set_state(AppState.INITIALIZING, "Loading transcription engine...")
self._initialize_engine()
def shutdown(self):
"""Gracefully shut down all components."""
# Stop transcription
if self.is_transcribing:
self.stop_transcription()
# Stop web server
if self.web_server_thread and self.web_server_thread.is_alive():
try:
if self.web_server_thread.loop:
self.web_server_thread.loop.call_soon_threadsafe(
self.web_server_thread.loop.stop
)
except Exception as e:
print(f"Warning: Error stopping web server: {e}")
# Stop transcription engine
if self.transcription_engine:
try:
self.transcription_engine.stop()
except Exception as e:
print(f"Warning: Error stopping engine: {e}")
# Wait for engine init thread
if self._engine_init_thread and self._engine_init_thread.is_alive():
self._engine_init_thread.join(timeout=5)
# ── Web Server ─────────────────────────────────────────────────
def _start_web_server(self):
"""Start the FastAPI web server for OBS display."""
try:
host = self.config.get('web_server.host', '127.0.0.1')
port = self.config.get('web_server.port', 8080)
# Gather display settings
ws_kwargs = self._get_web_server_kwargs(host, port)
# Try up to 5 ports
ports_to_try = [port] + [port + i for i in range(1, 5)]
for try_port in ports_to_try:
print(f"Attempting to start web server at http://{host}:{try_port}")
ws_kwargs['port'] = try_port
self.web_server = TranscriptionWebServer(**ws_kwargs)
self.web_server_thread = WebServerThread(self.web_server)
self.web_server_thread.start()
time.sleep(0.5)
if self.web_server_thread.error:
error_str = str(self.web_server_thread.error)
if "address already in use" in error_str.lower() or "errno 98" in error_str.lower():
print(f"Port {try_port} is in use, trying next port...")
self.web_server = None
self.web_server_thread = None
continue
else:
print(f"Web server failed to start: {self.web_server_thread.error}")
self.web_server = None
self.web_server_thread = None
break
else:
self.actual_web_port = try_port
print(f"Web server started at http://{host}:{try_port}")
return
print(f"WARNING: Could not start web server on any port")
except Exception as e:
print(f"ERROR: Failed to initialize web server: {e}")
self.web_server = None
self.web_server_thread = None
def _get_web_server_kwargs(self, host: str, port: int) -> dict:
"""Build kwargs dict for TranscriptionWebServer from config."""
return dict(
host=host,
port=port,
show_timestamps=self.config.get('display.show_timestamps', True),
fade_after_seconds=self.config.get('display.fade_after_seconds', 10),
max_lines=self.config.get('display.max_lines', 50),
font_family=self.config.get('display.font_family', 'Arial'),
font_size=self.config.get('display.font_size', 16),
fonts_dir=self.config.fonts_dir,
font_source=self.config.get('display.font_source', 'System Font'),
websafe_font=self.config.get('display.websafe_font', 'Arial'),
google_font=self.config.get('display.google_font', 'Roboto'),
user_color=self.config.get('display.user_color', '#4CAF50'),
text_color=self.config.get('display.text_color', '#FFFFFF'),
background_color=self.config.get('display.background_color', '#000000B3'),
)
# ── Transcription Engine ───────────────────────────────────────
def _initialize_engine(self):
"""Initialize the transcription engine in a background thread."""
device_config = self.config.get('transcription.device', 'auto')
self.device_manager.set_device(device_config)
audio_device_str = self.config.get('audio.input_device', 'default')
audio_device = None if audio_device_str == 'default' else int(audio_device_str)
model = self.config.get('transcription.model', 'base.en')
language = self.config.get('transcription.language', 'en')
device = self.device_manager.get_device_for_whisper()
compute_type = self.config.get('transcription.compute_type', 'default')
self.current_model_size = model
self.current_device_config = device_config
user_name = self.config.get('user.name', 'User')
continuous_mode = self.config.get('transcription.continuous_mode', False)
if continuous_mode:
post_speech_silence = 0.15
min_gap = 0.0
min_recording = 0.3
else:
post_speech_silence = self.config.get('transcription.post_speech_silence_duration', 0.3)
min_gap = self.config.get('transcription.min_gap_between_recordings', 0.0)
min_recording = self.config.get('transcription.min_length_of_recording', 0.5)
remote_mode = self.config.get('remote.mode', 'local')
if remote_mode in ('managed', 'byok'):
self.transcription_engine = DeepgramTranscriptionEngine(
config=self.config,
user_name=user_name,
input_device_index=audio_device,
)
self.transcription_engine.set_callbacks(
realtime_callback=self._on_realtime_transcription,
final_callback=self._on_final_transcription,
)
self.transcription_engine.set_error_callback(self._on_remote_error)
self.transcription_engine.set_credits_low_callback(self._on_credits_low)
else:
self.transcription_engine = RealtimeTranscriptionEngine(
model=model,
device=device,
language=language,
compute_type=compute_type,
enable_realtime_transcription=self.config.get('transcription.enable_realtime_transcription', False),
realtime_model=self.config.get('transcription.realtime_model', 'tiny.en'),
realtime_processing_pause=self.config.get('transcription.realtime_processing_pause', 0.1),
silero_sensitivity=self.config.get('transcription.silero_sensitivity', 0.4),
silero_use_onnx=self.config.get('transcription.silero_use_onnx', True),
webrtc_sensitivity=self.config.get('transcription.webrtc_sensitivity', 3),
post_speech_silence_duration=post_speech_silence,
min_length_of_recording=min_recording,
min_gap_between_recordings=min_gap,
pre_recording_buffer_duration=self.config.get('transcription.pre_recording_buffer_duration', 0.2),
beam_size=self.config.get('transcription.beam_size', 5),
initial_prompt=self.config.get('transcription.initial_prompt', ''),
no_log_file=self.config.get('transcription.no_log_file', True),
input_device_index=audio_device,
user_name=user_name,
)
self.transcription_engine.set_callbacks(
realtime_callback=self._on_realtime_transcription,
final_callback=self._on_final_transcription,
)
# Start init in background thread
self._engine_init_thread = EngineInitThread(
self.transcription_engine,
self._on_engine_ready,
)
self._engine_init_thread.start()
def _on_engine_ready(self, success: bool, message: str):
"""Called from EngineInitThread when engine init completes."""
if success:
remote_mode = self.config.get('remote.mode', 'local')
if remote_mode in ('managed', 'byok'):
mode_label = 'Managed' if remote_mode == 'managed' else 'BYOK'
device_display = f"Deepgram ({mode_label})"
elif self.transcription_engine:
actual_device = self.transcription_engine.device
compute_type = self.transcription_engine.compute_type
device_display = f"{actual_device.upper()} ({compute_type})"
else:
device_display = "Unknown"
self._set_state(AppState.READY, f"Ready | Device: {device_display}")
else:
self._set_state(AppState.ERROR, message)
# ── Transcription Control ──────────────────────────────────────
def start_transcription(self) -> tuple[bool, str]:
"""Start transcription. Returns (success, message)."""
if self.is_transcribing:
return False, "Already transcribing"
if not self.transcription_engine or not self.transcription_engine.is_ready():
return False, "Transcription engine not ready"
try:
success = self.transcription_engine.start_recording()
if not success:
return False, "Failed to start recording"
# Start server sync if enabled
if self.config.get('server_sync.enabled', False):
self._start_server_sync()
self.is_transcribing = True
self._set_state(AppState.TRANSCRIBING, "Transcribing...")
return True, "Transcription started"
except Exception as e:
return False, f"Failed to start transcription: {e}"
def stop_transcription(self) -> tuple[bool, str]:
"""Stop transcription. Returns (success, message)."""
if not self.is_transcribing:
return False, "Not transcribing"
try:
if self.transcription_engine:
self.transcription_engine.stop_recording()
if self.server_sync_client:
self.server_sync_client.stop()
self.server_sync_client = None
self.is_transcribing = False
self._set_state(AppState.READY, "Ready")
return True, "Transcription stopped"
except Exception as e:
return False, f"Failed to stop transcription: {e}"
def clear_transcriptions(self) -> int:
"""Clear stored transcriptions. Returns count of cleared items."""
count = len(self.transcriptions)
self.transcriptions.clear()
return count
def get_transcriptions_text(self, include_timestamps: bool = True) -> str:
"""Get all transcriptions as formatted text."""
lines = []
for result in self.transcriptions:
parts = []
if include_timestamps:
parts.append(f"[{result.timestamp.strftime('%H:%M:%S')}]")
if result.user_name and result.user_name.strip():
parts.append(f"{result.user_name}:")
parts.append(result.text)
lines.append(" ".join(parts))
return "\n".join(lines)
def reload_engine(self) -> tuple[bool, str]:
"""Reload the transcription engine with current config settings."""
try:
was_transcribing = self.is_transcribing
if was_transcribing:
self.stop_transcription()
self._set_state(AppState.RELOADING, "Reloading engine...")
# Wait for any existing init thread
if self._engine_init_thread and self._engine_init_thread.is_alive():
self._engine_init_thread.join(timeout=10)
# Stop current engine
if self.transcription_engine:
try:
self.transcription_engine.stop()
except Exception as e:
print(f"Warning: Error stopping engine: {e}")
# Re-initialize
self._initialize_engine()
return True, "Engine reload initiated"
except Exception as e:
self._set_state(AppState.ERROR, f"Engine reload failed: {e}")
return False, str(e)
# ── Transcription Callbacks ────────────────────────────────────
def _on_realtime_transcription(self, result: TranscriptionResult):
"""Handle realtime (preview) transcription."""
if not self.is_transcribing:
return
try:
# Broadcast to web server
if self.web_server and self.web_server_thread and self.web_server_thread.loop:
asyncio.run_coroutine_threadsafe(
self.web_server.broadcast_preview(
result.text, result.user_name, result.timestamp
),
self.web_server_thread.loop,
)
# Send to server sync
if self.server_sync_client:
self.server_sync_client.send_preview(result.text, result.timestamp)
# Notify frontend
if self.on_preview:
self.on_preview({
"text": result.text,
"user_name": result.user_name,
"timestamp": result.timestamp.strftime("%H:%M:%S") if result.timestamp else None,
"is_preview": True,
})
except Exception as e:
print(f"Error handling realtime transcription: {e}")
def _on_final_transcription(self, result: TranscriptionResult):
"""Handle final transcription."""
if not self.is_transcribing:
return
try:
self.transcriptions.append(result)
# Broadcast to web server
if self.web_server and self.web_server_thread and self.web_server_thread.loop:
asyncio.run_coroutine_threadsafe(
self.web_server.broadcast_transcription(
result.text, result.user_name, result.timestamp
),
self.web_server_thread.loop,
)
# Send to server sync
if self.server_sync_client:
self.server_sync_client.send_transcription(
result.text, result.timestamp
)
# Notify frontend
if self.on_transcription:
self.on_transcription({
"text": result.text,
"user_name": result.user_name,
"timestamp": result.timestamp.strftime("%H:%M:%S") if result.timestamp else None,
"is_preview": False,
})
except Exception as e:
print(f"Error handling final transcription: {e}")
def _on_remote_error(self, error_msg: str):
"""Handle error from remote transcription service."""
print(f"Remote transcription error: {error_msg}")
if self.on_error:
self.on_error(error_msg)
def _on_credits_low(self, seconds_remaining: int):
"""Handle low credits warning from proxy."""
if self.on_credits_low:
self.on_credits_low(seconds_remaining)
# ── Server Sync ────────────────────────────────────────────────
def _start_server_sync(self):
"""Start server sync client."""
try:
url = self.config.get('server_sync.url', '')
if not url:
print("Server sync enabled but no URL configured")
return
room = self.config.get('server_sync.room', 'default')
passphrase = self.config.get('server_sync.passphrase', '')
user_name = self.config.get('user.name', 'User')
fonts_dir = self.config.fonts_dir
font_source = self.config.get('display.font_source', 'System Font')
if font_source == "System Font":
font_source = "None"
self.server_sync_client = ServerSyncClient(
url=url,
room=room,
passphrase=passphrase,
user_name=user_name,
fonts_dir=fonts_dir,
font_source=font_source,
websafe_font=self.config.get('display.websafe_font', '') or None,
google_font=self.config.get('display.google_font', '') or None,
custom_font_file=self.config.get('display.custom_font_file', '') or None,
user_color=self.config.get('display.user_color', '#4CAF50'),
text_color=self.config.get('display.text_color', '#FFFFFF'),
background_color=self.config.get('display.background_color', '#000000B3'),
)
self.server_sync_client.start()
except Exception as e:
print(f"Error starting server sync: {e}")
# ── Configuration ──────────────────────────────────────────────
def apply_settings(self, new_config: Optional[dict] = None) -> tuple[bool, str]:
"""Apply settings changes. If new_config is provided, merge it first.
Returns (engine_reload_needed, message).
"""
if new_config:
for key, value in new_config.items():
self.config.set(key, value)
# Update web server display settings
if self.web_server:
self.web_server.show_timestamps = self.config.get('display.show_timestamps', True)
self.web_server.fade_after_seconds = self.config.get('display.fade_after_seconds', 10)
self.web_server.max_lines = self.config.get('display.max_lines', 50)
self.web_server.font_family = self.config.get('display.font_family', 'Arial')
self.web_server.font_size = self.config.get('display.font_size', 16)
self.web_server.font_source = self.config.get('display.font_source', 'System Font')
self.web_server.websafe_font = self.config.get('display.websafe_font', 'Arial')
self.web_server.google_font = self.config.get('display.google_font', 'Roboto')
self.web_server.user_color = self.config.get('display.user_color', '#4CAF50')
self.web_server.text_color = self.config.get('display.text_color', '#FFFFFF')
self.web_server.background_color = self.config.get('display.background_color', '#000000B3')
# Restart server sync if running
if self.is_transcribing and self.server_sync_client:
self.server_sync_client.stop()
self.server_sync_client = None
if self.config.get('server_sync.enabled', False):
self._start_server_sync()
# Check if model/device changed
new_model = self.config.get('transcription.model', 'base.en')
new_device = self.config.get('transcription.device', 'auto')
engine_reload_needed = (
self.current_model_size != new_model
or self.current_device_config != new_device
)
if engine_reload_needed:
self.reload_engine()
return True, "Settings applied. Engine reloading with new model/device."
else:
return False, "Settings applied successfully."
def get_status(self) -> dict:
"""Get current application status as a dict."""
host = self.config.get('web_server.host', '127.0.0.1')
port = self.actual_web_port or self.config.get('web_server.port', 8080)
device_info = self.device_manager.get_device_info()
remote_mode = self.config.get('remote.mode', 'local')
if remote_mode in ('managed', 'byok') and self.transcription_engine:
mode_label = 'Managed' if remote_mode == 'managed' else 'BYOK'
engine_device = f"Deepgram ({mode_label})"
elif self.transcription_engine and hasattr(self.transcription_engine, 'device'):
engine_device = f"{self.transcription_engine.device.upper()} ({self.transcription_engine.compute_type})"
else:
engine_device = "Not initialized"
return {
"state": self.state,
"is_transcribing": self.is_transcribing,
"version": __version__,
"engine_device": engine_device,
"web_server": {
"host": host,
"port": port,
"url": f"http://{host}:{port}",
"running": self.web_server_thread is not None and self.web_server_thread.is_alive(),
},
"transcription_count": len(self.transcriptions),
"remote_mode": remote_mode,
"server_sync_enabled": self.config.get('server_sync.enabled', False),
}
def get_audio_devices(self) -> list[dict]:
"""List available audio input devices."""
import sounddevice as sd
devices = []
try:
device_list = sd.query_devices()
for i, device in enumerate(device_list):
if device['max_input_channels'] > 0:
devices.append({"index": i, "name": device['name']})
except Exception:
pass
if not devices:
devices = [{"index": 0, "name": "Default"}]
return devices
def get_compute_devices(self) -> list[dict]:
"""List available compute devices."""
device_info = self.device_manager.get_device_info()
devices = [{"id": "auto", "name": "Auto-detect"}]
for dev_id, dev_name in device_info:
devices.append({"id": dev_id, "name": dev_name})
return devices
# ── Update Checking ────────────────────────────────────────────
def check_for_updates(self) -> dict:
"""Check for updates synchronously. Returns update info or None."""
from client.update_checker import UpdateChecker
gitea_url = self.config.get('updates.gitea_url', 'https://repo.anhonesthost.net')
owner = self.config.get('updates.owner', 'streamer-tools')
repo = self.config.get('updates.repo', 'local-transcription')
if not gitea_url or not owner or not repo:
return {"available": False, "error": "Update checking not configured"}
checker = UpdateChecker(
current_version=__version__,
gitea_url=gitea_url,
owner=owner,
repo=repo,
)
try:
release_info = checker.check_for_update()
self.config.set('updates.last_check', datetime.now().isoformat())
if release_info:
skipped = self.config.get('updates.skipped_versions', [])
return {
"available": True,
"version": release_info.version,
"download_url": release_info.download_url,
"release_notes": release_info.release_notes,
"skipped": release_info.version in skipped,
}
else:
return {"available": False, "current_version": __version__}
except Exception as e:
return {"available": False, "error": str(e)}
def skip_version(self, version: str):
"""Mark a version as skipped for update notifications."""
skipped = self.config.get('updates.skipped_versions', [])
if version not in skipped:
skipped.append(version)
self.config.set('updates.skipped_versions', skipped)
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Headless entry point for the Local Transcription backend.
Runs the transcription engine + API server without any GUI (no PySide6).
Designed to be launched as a Tauri sidecar or run standalone for development.
Usage:
python -m backend.main_headless [--port PORT] [--host HOST]
The backend prints the actual port to stdout as JSON on startup:
{"event": "ready", "port": 8080}
This allows the Tauri shell to discover which port the backend bound to.
"""
import argparse
import json
import multiprocessing
import os
import signal
import sys
from pathlib import Path
# Must be called before anything else for PyInstaller compatibility
multiprocessing.freeze_support()
if __name__ == "__main__":
try:
multiprocessing.set_start_method('spawn', force=True)
except RuntimeError:
pass
# Add project root to path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
os.chdir(project_root)
from client.instance_lock import InstanceLock
def main():
parser = argparse.ArgumentParser(description="Local Transcription headless backend")
parser.add_argument("--host", default="127.0.0.1", help="API server host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=8080, help="API server port (default: 8080)")
args = parser.parse_args()
instance_lock = InstanceLock()
if not instance_lock.acquire():
print(json.dumps({"event": "error", "message": "Another instance is already running"}),
flush=True)
sys.exit(1)
def handle_shutdown(signum, frame):
print(json.dumps({"event": "shutdown"}), flush=True)
if controller:
controller.shutdown()
instance_lock.release()
sys.exit(0)
signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)
controller = None
try:
from backend.app_controller import AppController
from backend.api_server import APIServer
# Override web server port from CLI arg
from client.config import Config
config = Config()
config.set('web_server.host', args.host)
config.set('web_server.port', args.port)
# Create controller and initialize
controller = AppController(config=config)
# Wire a state callback that prints the ready event
def on_state_changed(state, message):
event = {"event": "state", "state": state, "message": message}
print(json.dumps(event), flush=True)
controller.on_state_changed = on_state_changed
# Initialize engine + web server
controller.initialize()
# Create API server wrapping the controller
api_server = APIServer(controller)
# Determine actual port (web server may have shifted if port was in use)
actual_port = controller.actual_web_port or args.port
# Print ready event so Tauri can discover the port
print(json.dumps({"event": "ready", "port": actual_port}), flush=True)
# Run the API server (blocks)
import uvicorn
import logging
logging.getLogger("uvicorn").setLevel(logging.ERROR)
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
uvicorn.run(
api_server.app,
host=args.host,
port=actual_port + 1, # API on port+1, OBS display on the main port
log_level="error",
access_log=False,
)
except KeyboardInterrupt:
print(json.dumps({"event": "shutdown", "reason": "keyboard_interrupt"}), flush=True)
except Exception as e:
print(json.dumps({"event": "error", "message": str(e)}), flush=True)
import traceback
traceback.print_exc()
sys.exit(1)
finally:
if controller:
controller.shutdown()
instance_lock.release()
if __name__ == "__main__":
main()
+19
View File
@@ -48,6 +48,25 @@ class Config:
# Save the default configuration # Save the default configuration
self.save() self.save()
# Migrate remote_processing -> remote
self._migrate_remote_config()
def _migrate_remote_config(self):
"""Migrate old remote_processing config to new remote config."""
if 'remote_processing' in self.config and 'remote' not in self.config:
old = self.config['remote_processing']
self.config['remote'] = {
'mode': 'managed' if old.get('enabled', False) else 'local',
'server_url': old.get('server_url', ''),
'auth_token': '',
'byok_api_key': old.get('api_key', ''),
'deepgram_model': 'nova-2',
'language': 'en-US',
'fallback_to_local': old.get('fallback_to_local', True),
}
del self.config['remote_processing']
self.save()
def save(self) -> None: def save(self) -> None:
"""Save current configuration to file.""" """Save current configuration to file."""
with open(self.config_path, 'w') as f: with open(self.config_path, 'w') as f:
+528
View File
@@ -0,0 +1,528 @@
"""Deepgram-based transcription engine using WebSocket streaming.
Supports two modes:
- Managed mode: connects to a proxy server that handles Deepgram credentials
- BYOK mode: connects directly to the Deepgram API with a user-provided key
Implements the same duck-type interface as RealtimeTranscriptionEngine so
MainWindow can use it as a drop-in replacement.
"""
import asyncio
import json
import logging
import numpy as np
import threading
from datetime import datetime
from queue import Queue, Empty
from typing import Optional, Callable
from client.transcription_engine_realtime import TranscriptionResult
logger = logging.getLogger(__name__)
class DeepgramTranscriptionEngine:
"""
Transcription engine that streams audio to Deepgram via WebSocket.
In managed mode the connection goes through a proxy at
``wss://<server>/ws/transcribe`` which handles authentication and
Deepgram credentials. In BYOK (bring-your-own-key) mode the
connection goes directly to the Deepgram API.
"""
# ------------------------------------------------------------------ #
# Construction / configuration
# ------------------------------------------------------------------ #
def __init__(self, config, user_name: str = "User", input_device_index: Optional[int] = None):
"""
Initialise the engine from a :class:`client.config.Config` object.
Args:
config: Application ``Config`` instance.
user_name: Display name attached to transcriptions.
input_device_index: Index of the audio input device to use
(``None`` for the system default).
"""
self.config = config
self.user_name = user_name
self.input_device_index = input_device_index
# Mode: 'managed' (proxy) or 'byok' (direct Deepgram)
self.mode: str = config.get("remote.mode", "managed")
# Managed-mode settings
self.server_url: str = config.get("remote.server_url", "")
self.auth_token: str = config.get("remote.auth_token", "")
# BYOK-mode settings
self.byok_api_key: str = config.get("remote.byok_api_key", "")
# Deepgram model / language (used in both modes)
self.deepgram_model: str = config.get("remote.deepgram_model", "nova-2")
self.language: str = config.get("remote.language", "en-US")
# Audio parameters
self.sample_rate: int = 16000
self.channels: int = 1
self.blocksize: int = 4096
# Callbacks
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
self.final_callback: Optional[Callable[[TranscriptionResult], None]] = None
self._on_error: Optional[Callable[[str], None]] = None
self._on_credits_low: Optional[Callable[[int], None]] = None
# Internal state
self._is_initialized: bool = False
self._is_recording: bool = False
self._stop_event: threading.Event = threading.Event()
self._audio_queue: Queue = Queue()
# Asyncio event loop running in a daemon thread
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._thread: Optional[threading.Thread] = None
# WebSocket handle (set inside the async context)
self._ws = None
# sounddevice InputStream
self._stream = None
# ------------------------------------------------------------------ #
# Callback setters
# ------------------------------------------------------------------ #
def set_callbacks(
self,
realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None,
final_callback: Optional[Callable[[TranscriptionResult], None]] = None,
):
"""Set transcription result callbacks (matches RealtimeTranscriptionEngine API)."""
self.realtime_callback = realtime_callback
self.final_callback = final_callback
def set_error_callback(self, fn: Optional[Callable[[str], None]]):
"""Set a callback invoked on errors. ``fn`` receives a string message."""
self._on_error = fn
def set_credits_low_callback(self, fn: Optional[Callable[[int], None]]):
"""Set a callback for low-credit warnings. ``fn`` receives seconds remaining."""
self._on_credits_low = fn
# ------------------------------------------------------------------ #
# Public interface (duck-typed with RealtimeTranscriptionEngine)
# ------------------------------------------------------------------ #
def initialize(self) -> bool:
"""Validate configuration and mark the engine as ready.
Returns ``True`` when the engine is ready to start recording.
"""
if self._is_initialized:
return True
if self.mode == "managed":
if not self.server_url:
logger.error("Managed mode requires a server URL (remote.server_url)")
return False
if not self.auth_token:
logger.error("Managed mode requires an auth token (remote.auth_token)")
return False
elif self.mode == "byok":
if not self.byok_api_key:
logger.error("BYOK mode requires an API key (remote.byok_api_key)")
return False
else:
logger.error("Unknown remote mode: %s (expected 'managed' or 'byok')", self.mode)
return False
self._is_initialized = True
logger.info("DeepgramTranscriptionEngine initialised in %s mode", self.mode)
return True
def start_recording(self) -> bool:
"""Open the audio stream and connect the WebSocket.
Returns ``True`` on success.
"""
if not self._is_initialized:
logger.error("Engine not initialised -- call initialize() first")
return False
if self._is_recording:
return True
self._stop_event.clear()
self._is_recording = True
# Start the asyncio event-loop thread (handles WS send/receive)
self._thread = threading.Thread(target=self._run_event_loop, daemon=True)
self._thread.start()
# Start the audio capture stream
try:
self._start_audio_stream()
except Exception as exc:
logger.error("Failed to open audio stream: %s", exc)
self._is_recording = False
self._stop_event.set()
return False
logger.info("Recording started")
return True
def stop_recording(self):
"""Stop audio capture and close the WebSocket."""
if not self._is_recording:
return
self._is_recording = False
self._stop_event.set()
# Stop audio stream
self._stop_audio_stream()
# Close WebSocket from outside the event-loop thread
if self._ws is not None and self._loop is not None and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(self._close_ws(), self._loop)
# Wait for the thread to finish
if self._thread is not None:
self._thread.join(timeout=5)
self._thread = None
logger.info("Recording stopped")
def stop(self):
"""Full shutdown -- stop recording and release all resources."""
self.stop_recording()
self._is_initialized = False
logger.info("DeepgramTranscriptionEngine shut down")
def is_ready(self) -> bool:
"""Return ``True`` if the engine has been successfully initialised."""
return self._is_initialized
# ------------------------------------------------------------------ #
# Audio capture (sounddevice)
# ------------------------------------------------------------------ #
def _start_audio_stream(self):
"""Open a ``sounddevice.InputStream`` that feeds the audio queue."""
import sounddevice as sd
def _audio_callback(indata, frames, time_info, status): # noqa: ARG001
if status:
logger.warning("Audio stream status: %s", status)
if self._is_recording:
# float32 -> int16 PCM bytes
pcm = (indata * 32767).astype(np.int16).tobytes()
self._audio_queue.put(pcm)
self._stream = sd.InputStream(
samplerate=self.sample_rate,
blocksize=self.blocksize,
channels=self.channels,
dtype="float32",
device=self.input_device_index,
callback=_audio_callback,
)
self._stream.start()
def _stop_audio_stream(self):
"""Close the audio input stream."""
if self._stream is not None:
try:
self._stream.stop()
self._stream.close()
except Exception as exc:
logger.debug("Error closing audio stream: %s", exc)
finally:
self._stream = None
# ------------------------------------------------------------------ #
# Asyncio event-loop (runs in daemon thread)
# ------------------------------------------------------------------ #
def _run_event_loop(self):
"""Entry point for the daemon thread -- runs the async event loop."""
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._ws_lifecycle())
except Exception as exc:
logger.error("Event-loop error: %s", exc)
finally:
try:
self._loop.run_until_complete(self._loop.shutdown_asyncgens())
except Exception:
pass
self._loop.close()
self._loop = None
async def _ws_lifecycle(self):
"""Connect, authenticate (if managed), then run send/receive loops."""
import websockets
try:
ws_url, extra_headers = self._build_ws_url_and_headers()
logger.info("Connecting to %s", ws_url)
self._ws = await websockets.connect(
ws_url,
additional_headers=extra_headers,
ping_interval=20,
ping_timeout=10,
)
# Managed mode: send auth message and wait for ready
if self.mode == "managed":
if not await self._managed_handshake():
return
# Run send and receive concurrently
await asyncio.gather(
self._send_loop(),
self._receive_loop(),
)
except asyncio.CancelledError:
pass
except Exception as exc:
msg = f"WebSocket error: {exc}"
logger.error(msg)
if self._on_error:
self._on_error(msg)
finally:
await self._close_ws()
def _build_ws_url_and_headers(self):
"""Return ``(url, headers)`` depending on the current mode."""
if self.mode == "managed":
# Ensure the server URL uses wss:// and append the path
url = self.server_url.rstrip("/")
if not url.startswith("ws://") and not url.startswith("wss://"):
url = f"wss://{url}"
url = f"{url}/ws/transcribe"
return url, {}
# BYOK -- connect directly to Deepgram
params = (
f"model={self.deepgram_model}"
f"&language={self.language}"
"&interim_results=true"
"&encoding=linear16"
f"&sample_rate={self.sample_rate}"
f"&channels={self.channels}"
)
url = f"wss://api.deepgram.com/v1/listen?{params}"
headers = {"Authorization": f"Token {self.byok_api_key}"}
return url, headers
# -- managed-mode handshake ---------------------------------------- #
async def _managed_handshake(self) -> bool:
"""Send auth message and wait for ``ready`` (managed mode).
Returns ``True`` on success.
"""
auth_msg = {
"type": "auth",
"token": self.auth_token,
"config": {
"model": self.deepgram_model,
"language": self.language,
"sample_rate": self.sample_rate,
"channels": self.channels,
"encoding": "linear16",
"interim_results": True,
},
}
await self._ws.send(json.dumps(auth_msg))
try:
raw = await asyncio.wait_for(self._ws.recv(), timeout=15)
data = json.loads(raw)
if data.get("type") == "ready":
logger.info("Managed proxy is ready")
return True
if data.get("type") == "error":
err = data.get("message", "unknown error")
logger.error("Auth error from proxy: %s", err)
if self._on_error:
self._on_error(f"Proxy auth error: {err}")
return False
logger.warning("Unexpected handshake message: %s", data)
return False
except asyncio.TimeoutError:
logger.error("Timed out waiting for proxy ready message")
if self._on_error:
self._on_error("Timed out waiting for proxy ready message")
return False
# -- send loop ----------------------------------------------------- #
async def _send_loop(self):
"""Drain the audio queue and push raw PCM bytes over the WebSocket."""
while not self._stop_event.is_set():
try:
pcm_bytes = self._audio_queue.get(timeout=0.1)
except Empty:
continue
try:
await self._ws.send(pcm_bytes)
except Exception as exc:
if not self._stop_event.is_set():
logger.error("Send error: %s", exc)
break
# -- receive loop -------------------------------------------------- #
async def _receive_loop(self):
"""Listen for messages from the WebSocket and dispatch them."""
while not self._stop_event.is_set():
try:
raw = await asyncio.wait_for(self._ws.recv(), timeout=1.0)
except asyncio.TimeoutError:
continue
except Exception as exc:
if not self._stop_event.is_set():
logger.error("Receive error: %s", exc)
break
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
logger.debug("Non-JSON message received, ignoring")
continue
if self.mode == "managed":
self._handle_managed_message(data)
else:
self._handle_byok_message(data)
# ------------------------------------------------------------------ #
# Message handlers
# ------------------------------------------------------------------ #
def _handle_managed_message(self, data: dict):
"""Process a message from the managed proxy."""
msg_type = data.get("type", "")
if msg_type == "transcript":
text = data.get("text", "")
is_final = data.get("is_final", False)
if text.strip():
result = TranscriptionResult(
text=text,
is_final=is_final,
timestamp=datetime.now(),
user_name=self.user_name,
)
if is_final:
if self.final_callback:
self.final_callback(result)
else:
if self.realtime_callback:
self.realtime_callback(result)
elif msg_type == "credits_low":
seconds_remaining = data.get("seconds_remaining", 0)
logger.warning("Credits low -- %d seconds remaining", seconds_remaining)
if self._on_credits_low:
self._on_credits_low(int(seconds_remaining))
elif msg_type == "error":
code = data.get("code", "")
message = data.get("message", "Unknown error")
logger.error("Proxy error [%s]: %s", code, message)
if self._on_error:
self._on_error(f"[{code}] {message}" if code else message)
elif msg_type == "session_end":
seconds_used = data.get("seconds_used", 0)
logger.info("Session ended -- %d seconds used", seconds_used)
elif msg_type == "ready":
# May arrive again after reconnects; safe to ignore.
logger.debug("Received ready message (already connected)")
else:
logger.debug("Unhandled managed message type: %s", msg_type)
def _handle_byok_message(self, data: dict):
"""Process a message received directly from the Deepgram API."""
msg_type = data.get("type", "")
if msg_type == "Results":
channel = data.get("channel", {})
alternatives = channel.get("alternatives", [])
if not alternatives:
return
transcript = alternatives[0].get("transcript", "")
is_final = data.get("is_final", False)
if transcript.strip():
result = TranscriptionResult(
text=transcript,
is_final=is_final,
timestamp=datetime.now(),
user_name=self.user_name,
)
if is_final:
if self.final_callback:
self.final_callback(result)
else:
if self.realtime_callback:
self.realtime_callback(result)
elif msg_type == "Metadata":
logger.debug("Deepgram metadata: %s", data)
elif msg_type == "UtteranceEnd":
logger.debug("Deepgram utterance end")
else:
logger.debug("Unhandled Deepgram message type: %s", msg_type)
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
async def _close_ws(self):
"""Close the WebSocket connection if open."""
if self._ws is not None:
try:
await self._ws.close()
except Exception:
pass
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:
"""Return ``True`` if audio is currently being captured."""
return self._is_recording
def __repr__(self) -> str:
return (
f"DeepgramTranscriptionEngine(mode={self.mode}, "
f"recording={self._is_recording})"
)
def __del__(self):
"""Best-effort cleanup."""
try:
self.stop()
except Exception:
pass
+8 -5
View File
@@ -68,11 +68,14 @@ web_server:
port: 8080 port: 8080
host: "127.0.0.1" host: "127.0.0.1"
remote_processing: remote:
enabled: false # Enable remote transcription offloading mode: local # local | managed | byok
server_url: "" # WebSocket URL of remote transcription service (e.g., ws://your-server:8765/ws/transcribe) server_url: "" # Proxy server URL for managed mode (e.g., wss://your-proxy.com)
api_key: "" # API key for authentication auth_token: "" # JWT stored after login (managed mode)
fallback_to_local: true # Fall back to local processing if remote fails byok_api_key: "" # Deepgram API key for BYOK mode
deepgram_model: nova-2 # Deepgram model to use
language: en-US # Language code
fallback_to_local: true # Fall back to local Whisper if remote fails
updates: updates:
auto_check: true # Check for updates on startup auto_check: true # Check for updates on startup
+38 -2
View File
@@ -18,6 +18,7 @@ sys.path.append(str(Path(__file__).resolve().parent.parent))
from client.config import Config from client.config import Config
from client.device_utils import DeviceManager from client.device_utils import DeviceManager
from client.transcription_engine_realtime import RealtimeTranscriptionEngine, TranscriptionResult from client.transcription_engine_realtime import RealtimeTranscriptionEngine, TranscriptionResult
from client.deepgram_transcription import DeepgramTranscriptionEngine
from client.server_sync import ServerSyncClient from client.server_sync import ServerSyncClient
from gui.settings_dialog_qt import SettingsDialog from gui.settings_dialog_qt import SettingsDialog
from server.web_display import TranscriptionWebServer from server.web_display import TranscriptionWebServer
@@ -394,6 +395,23 @@ class MainWindow(QMainWindow):
min_gap = self.config.get('transcription.min_gap_between_recordings', 0.0) min_gap = self.config.get('transcription.min_gap_between_recordings', 0.0)
min_recording = self.config.get('transcription.min_length_of_recording', 0.5) min_recording = self.config.get('transcription.min_length_of_recording', 0.5)
remote_mode = self.config.get('remote.mode', 'local')
if remote_mode in ('managed', 'byok'):
# Use Deepgram-based remote transcription
self.transcription_engine = DeepgramTranscriptionEngine(
config=self.config,
user_name=user_name,
input_device_index=audio_device
)
self.transcription_engine.set_callbacks(
realtime_callback=self._on_realtime_transcription,
final_callback=self._on_final_transcription
)
self.transcription_engine.set_error_callback(self._on_remote_error)
self.transcription_engine.set_credits_low_callback(self._on_credits_low)
else:
# Use local Whisper transcription
self.transcription_engine = RealtimeTranscriptionEngine( self.transcription_engine = RealtimeTranscriptionEngine(
model=model, model=model,
device=device, device=device,
@@ -430,8 +448,11 @@ class MainWindow(QMainWindow):
def _on_engine_ready(self, success: bool, message: str): def _on_engine_ready(self, success: bool, message: str):
"""Handle engine initialization completion.""" """Handle engine initialization completion."""
if success: if success:
# Update device label with actual device used remote_mode = self.config.get('remote.mode', 'local')
if self.transcription_engine: if remote_mode in ('managed', 'byok'):
mode_label = 'Managed' if remote_mode == 'managed' else 'BYOK'
self.device_label.setText(f"Device: Deepgram ({mode_label})")
elif self.transcription_engine:
actual_device = self.transcription_engine.device actual_device = self.transcription_engine.device
compute_type = self.transcription_engine.compute_type compute_type = self.transcription_engine.compute_type
device_display = f"{actual_device.upper()} ({compute_type})" device_display = f"{actual_device.upper()} ({compute_type})"
@@ -647,6 +668,21 @@ class MainWindow(QMainWindow):
import traceback import traceback
traceback.print_exc() traceback.print_exc()
def _on_remote_error(self, error_msg: str):
"""Handle error from remote transcription service."""
print(f"Remote transcription error: {error_msg}")
self.status_label.setText(f"⚠ Remote error: {error_msg}")
# Fallback to local if enabled
if self.config.get('remote.fallback_to_local', True) and self.is_transcribing:
print("Falling back to local transcription...")
self.status_label.setText("⚠ Remote failed — falling back to local")
def _on_credits_low(self, seconds_remaining: int):
"""Handle low credits warning from proxy."""
minutes = seconds_remaining // 60
self.status_label.setText(f"⚠ Credits low: {minutes} min remaining")
def _clear_transcriptions(self): def _clear_transcriptions(self):
"""Clear all transcriptions.""" """Clear all transcriptions."""
if not self.transcriptions: if not self.transcriptions:
+306 -46
View File
@@ -4,7 +4,7 @@ from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QLineEdit, QComboBox, QCheckBox, QSlider, QLabel, QLineEdit, QComboBox, QCheckBox, QSlider,
QPushButton, QMessageBox, QGroupBox, QScrollArea, QWidget, QPushButton, QMessageBox, QGroupBox, QScrollArea, QWidget,
QFileDialog, QColorDialog QFileDialog, QColorDialog, QRadioButton
) )
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QScreen, QFontDatabase, QColor from PySide6.QtGui import QScreen, QFontDatabase, QColor
@@ -487,46 +487,91 @@ class SettingsDialog(QDialog):
server_group.setLayout(server_layout) server_group.setLayout(server_layout)
content_layout.addWidget(server_group) content_layout.addWidget(server_group)
# Remote Processing Group # Transcription Mode Group
remote_group = QGroupBox("Remote Processing (GPU Offload)") mode_group = QGroupBox("Transcription Mode")
remote_layout = QFormLayout() mode_layout = QVBoxLayout()
remote_layout.setSpacing(10) mode_layout.setSpacing(10)
self.remote_enabled_check = QCheckBox() # Radio buttons for mode selection
self.remote_enabled_check.setToolTip( self.mode_local_radio = QRadioButton("Local (Whisper)")
"Enable remote transcription processing:\n" self.mode_local_radio.setToolTip("Transcribe locally using Whisper models")
"• Offload transcription to a GPU-equipped server\n" self.mode_managed_radio = QRadioButton("Remote - Managed")
"• Reduces local CPU/GPU usage\n" self.mode_managed_radio.setToolTip("Use the transcription proxy service with prepaid credits")
"• Requires running the remote transcription service" self.mode_byok_radio = QRadioButton("Remote - BYOK (Bring Your Own Key)")
) self.mode_byok_radio.setToolTip("Connect directly to Deepgram with your own API key")
remote_layout.addRow("Enable Remote Processing:", self.remote_enabled_check)
self.remote_url_input = QLineEdit() mode_layout.addWidget(self.mode_local_radio)
self.remote_url_input.setPlaceholderText("ws://your-server:8765/ws/transcribe") mode_layout.addWidget(self.mode_managed_radio)
self.remote_url_input.setToolTip( mode_layout.addWidget(self.mode_byok_radio)
"WebSocket URL of the remote transcription service:\n"
"• Format: ws://host:port/ws/transcribe\n"
"• Use wss:// for secure connections"
)
remote_layout.addRow("Server URL:", self.remote_url_input)
self.remote_api_key_input = QLineEdit() # Managed mode fields (shown when managed radio selected)
self.remote_api_key_input.setEchoMode(QLineEdit.Password) self.managed_widget = QWidget()
self.remote_api_key_input.setPlaceholderText("your-api-key") managed_layout = QFormLayout()
self.remote_api_key_input.setToolTip( managed_layout.setSpacing(8)
"API key for authentication with the remote service"
)
remote_layout.addRow("API Key:", self.remote_api_key_input)
self.remote_fallback_check = QCheckBox("Enable") self.managed_server_url = QLineEdit()
self.remote_fallback_check.setChecked(True) self.managed_server_url.setPlaceholderText("wss://your-proxy-server.com")
self.remote_fallback_check.setToolTip( managed_layout.addRow("Server URL:", self.managed_server_url)
"Fall back to local transcription if remote service is unavailable"
)
remote_layout.addRow("Fallback to Local:", self.remote_fallback_check)
remote_group.setLayout(remote_layout) # Login/Register buttons in a row
content_layout.addWidget(remote_group) auth_widget = QWidget()
auth_layout = QHBoxLayout()
auth_layout.setContentsMargins(0, 0, 0, 0)
self.managed_login_btn = QPushButton("Login")
self.managed_login_btn.clicked.connect(self._managed_login)
self.managed_register_btn = QPushButton("Register")
self.managed_register_btn.clicked.connect(self._managed_register)
auth_layout.addWidget(self.managed_login_btn)
auth_layout.addWidget(self.managed_register_btn)
auth_layout.addStretch()
auth_widget.setLayout(auth_layout)
managed_layout.addRow("Account:", auth_widget)
self.managed_balance_label = QLabel("Not logged in")
managed_layout.addRow("Balance:", self.managed_balance_label)
self.managed_fallback_check = QCheckBox("Enable")
self.managed_fallback_check.setChecked(True)
self.managed_fallback_check.setToolTip("Fall back to local Whisper if remote fails")
managed_layout.addRow("Fallback to Local:", self.managed_fallback_check)
self.managed_widget.setLayout(managed_layout)
mode_layout.addWidget(self.managed_widget)
# BYOK mode fields (shown when BYOK radio selected)
self.byok_widget = QWidget()
byok_layout = QFormLayout()
byok_layout.setSpacing(8)
self.byok_api_key_input = QLineEdit()
self.byok_api_key_input.setEchoMode(QLineEdit.Password)
self.byok_api_key_input.setPlaceholderText("your-deepgram-api-key")
byok_layout.addRow("Deepgram API Key:", self.byok_api_key_input)
self.byok_model_combo = QComboBox()
self.byok_model_combo.addItems(["nova-2", "nova-2-general", "nova-2-meeting", "nova-2-phonecall", "whisper-large", "whisper-medium", "whisper-small"])
byok_layout.addRow("Model:", self.byok_model_combo)
self.byok_language_input = QLineEdit()
self.byok_language_input.setText("en-US")
self.byok_language_input.setPlaceholderText("en-US")
byok_layout.addRow("Language:", self.byok_language_input)
self.byok_fallback_check = QCheckBox("Enable")
self.byok_fallback_check.setChecked(True)
self.byok_fallback_check.setToolTip("Fall back to local Whisper if Deepgram fails")
byok_layout.addRow("Fallback to Local:", self.byok_fallback_check)
self.byok_widget.setLayout(byok_layout)
mode_layout.addWidget(self.byok_widget)
mode_group.setLayout(mode_layout)
content_layout.addWidget(mode_group)
# Connect radio buttons to show/hide relevant widgets
self.mode_local_radio.toggled.connect(self._on_mode_changed)
self.mode_managed_radio.toggled.connect(self._on_mode_changed)
self.mode_byok_radio.toggled.connect(self._on_mode_changed)
# Updates Group # Updates Group
updates_group = QGroupBox("Software Updates") updates_group = QGroupBox("Software Updates")
@@ -794,11 +839,28 @@ class SettingsDialog(QDialog):
self.server_room_input.setText(self.config.get('server_sync.room', 'default')) self.server_room_input.setText(self.config.get('server_sync.room', 'default'))
self.server_passphrase_input.setText(self.config.get('server_sync.passphrase', '')) self.server_passphrase_input.setText(self.config.get('server_sync.passphrase', ''))
# Remote processing settings # Transcription mode settings
self.remote_enabled_check.setChecked(self.config.get('remote_processing.enabled', False)) mode = self.config.get('remote.mode', 'local')
self.remote_url_input.setText(self.config.get('remote_processing.server_url', '')) if mode == 'managed':
self.remote_api_key_input.setText(self.config.get('remote_processing.api_key', '')) self.mode_managed_radio.setChecked(True)
self.remote_fallback_check.setChecked(self.config.get('remote_processing.fallback_to_local', True)) elif mode == 'byok':
self.mode_byok_radio.setChecked(True)
else:
self.mode_local_radio.setChecked(True)
self.managed_server_url.setText(self.config.get('remote.server_url', ''))
self.managed_fallback_check.setChecked(self.config.get('remote.fallback_to_local', True))
self.byok_api_key_input.setText(self.config.get('remote.byok_api_key', ''))
self.byok_model_combo.setCurrentText(self.config.get('remote.deepgram_model', 'nova-2'))
self.byok_language_input.setText(self.config.get('remote.language', 'en-US'))
self.byok_fallback_check.setChecked(self.config.get('remote.fallback_to_local', True))
# Trigger visibility update
self._on_mode_changed()
# Update balance if managed mode and has token
if self.config.get('remote.auth_token'):
self._update_managed_balance()
# Update settings # Update settings
self.update_auto_check.setChecked(self.config.get('updates.auto_check', True)) self.update_auto_check.setChecked(self.config.get('updates.auto_check', True))
@@ -869,11 +931,21 @@ class SettingsDialog(QDialog):
self.config.set('server_sync.room', self.server_room_input.text()) self.config.set('server_sync.room', self.server_room_input.text())
self.config.set('server_sync.passphrase', self.server_passphrase_input.text()) self.config.set('server_sync.passphrase', self.server_passphrase_input.text())
# Remote processing settings # Transcription mode settings
self.config.set('remote_processing.enabled', self.remote_enabled_check.isChecked()) if self.mode_managed_radio.isChecked():
self.config.set('remote_processing.server_url', self.remote_url_input.text()) self.config.set('remote.mode', 'managed')
self.config.set('remote_processing.api_key', self.remote_api_key_input.text()) elif self.mode_byok_radio.isChecked():
self.config.set('remote_processing.fallback_to_local', self.remote_fallback_check.isChecked()) self.config.set('remote.mode', 'byok')
else:
self.config.set('remote.mode', 'local')
self.config.set('remote.server_url', self.managed_server_url.text())
self.config.set('remote.fallback_to_local',
self.managed_fallback_check.isChecked() if self.mode_managed_radio.isChecked()
else self.byok_fallback_check.isChecked())
self.config.set('remote.byok_api_key', self.byok_api_key_input.text())
self.config.set('remote.deepgram_model', self.byok_model_combo.currentText())
self.config.set('remote.language', self.byok_language_input.text())
# Update settings # Update settings
self.config.set('updates.auto_check', self.update_auto_check.isChecked()) self.config.set('updates.auto_check', self.update_auto_check.isChecked())
@@ -892,6 +964,194 @@ class SettingsDialog(QDialog):
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save settings:\n{e}") QMessageBox.critical(self, "Error", f"Failed to save settings:\n{e}")
def _on_mode_changed(self):
"""Show/hide mode-specific widgets based on selected radio button."""
self.managed_widget.setVisible(self.mode_managed_radio.isChecked())
self.byok_widget.setVisible(self.mode_byok_radio.isChecked())
def _managed_login(self):
"""Open a login dialog and authenticate with the managed proxy server."""
import json
import urllib.request
import urllib.error
dialog = QDialog(self)
dialog.setWindowTitle("Login")
dialog.setMinimumWidth(350)
layout = QFormLayout()
email_input = QLineEdit()
email_input.setPlaceholderText("you@example.com")
layout.addRow("Email:", email_input)
password_input = QLineEdit()
password_input.setEchoMode(QLineEdit.Password)
layout.addRow("Password:", password_input)
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.reject)
login_btn = QPushButton("Login")
login_btn.setDefault(True)
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(login_btn)
layout.addRow("", button_layout)
dialog.setLayout(layout)
def do_login():
server_url = self.managed_server_url.text().rstrip('/')
if not server_url:
QMessageBox.warning(dialog, "Error", "Please enter a Server URL first.")
return
payload = json.dumps({
"email": email_input.text(),
"password": password_input.text()
}).encode('utf-8')
req = urllib.request.Request(
f"{server_url}/auth/login",
data=payload,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode('utf-8'))
token = data.get('token', '')
if token:
self.config.set('remote.auth_token', token)
self._update_managed_balance()
QMessageBox.information(dialog, "Success", "Logged in successfully.")
dialog.accept()
else:
QMessageBox.warning(dialog, "Error", "Login succeeded but no token received.")
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode('utf-8'))
msg = body.get('detail', body.get('message', str(e)))
except Exception:
msg = str(e)
QMessageBox.warning(dialog, "Login Failed", msg)
except Exception as e:
QMessageBox.warning(dialog, "Error", f"Could not connect to server:\n{e}")
login_btn.clicked.connect(do_login)
dialog.exec()
def _managed_register(self):
"""Open a registration dialog and create an account on the managed proxy server."""
import json
import urllib.request
import urllib.error
dialog = QDialog(self)
dialog.setWindowTitle("Register")
dialog.setMinimumWidth(350)
layout = QFormLayout()
email_input = QLineEdit()
email_input.setPlaceholderText("you@example.com")
layout.addRow("Email:", email_input)
password_input = QLineEdit()
password_input.setEchoMode(QLineEdit.Password)
layout.addRow("Password:", password_input)
confirm_input = QLineEdit()
confirm_input.setEchoMode(QLineEdit.Password)
layout.addRow("Confirm Password:", confirm_input)
button_layout = QHBoxLayout()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(dialog.reject)
register_btn = QPushButton("Register")
register_btn.setDefault(True)
button_layout.addStretch()
button_layout.addWidget(cancel_btn)
button_layout.addWidget(register_btn)
layout.addRow("", button_layout)
dialog.setLayout(layout)
def do_register():
if password_input.text() != confirm_input.text():
QMessageBox.warning(dialog, "Error", "Passwords do not match.")
return
server_url = self.managed_server_url.text().rstrip('/')
if not server_url:
QMessageBox.warning(dialog, "Error", "Please enter a Server URL first.")
return
payload = json.dumps({
"email": email_input.text(),
"password": password_input.text()
}).encode('utf-8')
req = urllib.request.Request(
f"{server_url}/auth/register",
data=payload,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode('utf-8'))
token = data.get('token', '')
if token:
self.config.set('remote.auth_token', token)
self._update_managed_balance()
QMessageBox.information(dialog, "Success", "Account created and logged in.")
dialog.accept()
else:
QMessageBox.information(dialog, "Success",
"Account created. Please log in.")
dialog.accept()
except urllib.error.HTTPError as e:
try:
body = json.loads(e.read().decode('utf-8'))
msg = body.get('detail', body.get('message', str(e)))
except Exception:
msg = str(e)
QMessageBox.warning(dialog, "Registration Failed", msg)
except Exception as e:
QMessageBox.warning(dialog, "Error", f"Could not connect to server:\n{e}")
register_btn.clicked.connect(do_register)
dialog.exec()
def _update_managed_balance(self):
"""Fetch and display the current account balance from the managed proxy server."""
import json
import urllib.request
import urllib.error
server_url = self.managed_server_url.text().rstrip('/')
token = self.config.get('remote.auth_token', '')
if not server_url or not token:
self.managed_balance_label.setText("Not logged in")
return
req = urllib.request.Request(
f"{server_url}/billing/balance",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
},
method="GET"
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode('utf-8'))
balance = data.get('balance', data.get('credits', 'N/A'))
self.managed_balance_label.setText(str(balance))
except urllib.error.HTTPError as e:
if e.code == 401:
self.managed_balance_label.setText("Session expired - please login again")
self.config.set('remote.auth_token', '')
else:
self.managed_balance_label.setText("Error fetching balance")
except Exception:
self.managed_balance_label.setText("Could not connect to server")
def _check_for_updates_now(self): def _check_for_updates_now(self):
"""Manually check for updates.""" """Manually check for updates."""
from version import __version__ from version import __version__
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/LocalTranscription.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Local Transcription</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec file for headless Local Transcription backend (no PySide6/Qt).
This builds the Python sidecar for the Tauri frontend.
Much simpler than local-transcription.spec since all Qt dependencies are removed.
"""
import sys
import os
block_cipher = None
is_windows = sys.platform == 'win32'
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
# Find faster_whisper assets folder
import faster_whisper
faster_whisper_path = os.path.dirname(faster_whisper.__file__)
vad_assets_path = os.path.join(faster_whisper_path, 'assets')
# pvporcupine resources (indirect dependency from RealtimeSTT)
try:
import pvporcupine
pvporcupine_path = os.path.dirname(pvporcupine.__file__)
pvporcupine_resources = os.path.join(pvporcupine_path, 'resources')
pvporcupine_lib = os.path.join(pvporcupine_path, 'lib')
pvporcupine_data_files = []
if os.path.exists(pvporcupine_resources):
pvporcupine_data_files.append((pvporcupine_resources, 'pvporcupine/resources'))
if os.path.exists(pvporcupine_lib):
pvporcupine_data_files.append((pvporcupine_lib, 'pvporcupine/lib'))
except ImportError:
pvporcupine_data_files = []
# Data files
datas = [
('config/default_config.yaml', 'config'),
(vad_assets_path, 'faster_whisper/assets'),
] + pvporcupine_data_files
# Hidden imports -- NO PySide6/Qt needed for headless backend
hiddenimports = [
# Transcription engine
'faster_whisper',
'faster_whisper.transcribe',
'faster_whisper.vad',
'ctranslate2',
'sounddevice',
'scipy',
'scipy.signal',
'numpy',
# RealtimeSTT
'RealtimeSTT',
'RealtimeSTT.audio_recorder',
'webrtcvad',
'webrtcvad_wheels',
'silero_vad',
# PyTorch
'torch',
'torch.nn',
'torch.nn.functional',
'torchaudio',
'onnxruntime',
'onnxruntime.capi',
'onnxruntime.capi.onnxruntime_pybind11_state',
'pyaudio',
'halo',
'colorama',
# FastAPI and dependencies
'fastapi',
'fastapi.routing',
'fastapi.responses',
'starlette',
'starlette.applications',
'starlette.routing',
'starlette.responses',
'starlette.websockets',
'starlette.middleware',
'starlette.middleware.cors',
'pydantic',
'pydantic.fields',
'pydantic.main',
'anyio',
'anyio._backends',
'anyio._backends._asyncio',
'sniffio',
# Uvicorn
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.http.h11_impl',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.protocols.websockets.wsproto_impl',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'h11',
'websockets',
'websockets.legacy',
'websockets.legacy.server',
# HTTP client
'requests',
'urllib3',
'certifi',
'charset_normalizer',
]
# Collect submodules for key packages
print("Collecting submodules for backend packages...")
for package in ['fastapi', 'starlette', 'pydantic', 'pydantic_core', 'anyio', 'uvicorn', 'websockets', 'h11', 'httptools', 'uvloop']:
try:
submodules = collect_submodules(package)
hiddenimports += submodules
print(f" + Collected {len(submodules)} submodules from {package}")
except Exception as e:
print(f" - Warning: Could not collect {package}: {e}")
# Collect data files
for package in ['fastapi', 'starlette', 'pydantic', 'uvicorn', 'RealtimeSTT']:
try:
data_files = collect_data_files(package)
if data_files:
datas += data_files
print(f" + Collected {len(data_files)} data files from {package}")
except Exception:
pass
# Pydantic critical deps
hiddenimports += [
'colorsys', 'decimal', 'json', 'ipaddress', 'pathlib', 'uuid',
'email.message', 'typing_extensions',
]
a = Analysis(
['backend/main_headless.py'],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=['hooks'],
hooksconfig={},
runtime_hooks=[],
excludes=['enum34', 'PySide6', 'PyQt5', 'PyQt6', 'tkinter'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='local-transcription-backend',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True, # Headless backend needs console for JSON output
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='LocalTranscription.ico' if is_windows else None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='local-transcription-backend',
)
+1784
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "local-transcription",
"private": true,
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2.0.0",
"@tsconfig/svelte": "^5.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.0",
"vite": "^6.0.0"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0"
}
}
+5172
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "local-transcription"
version = "1.4.0"
description = "Real-time speech-to-text transcription for streamers"
authors = ["Local Transcription Contributors"]
edition = "2021"
[lib]
name = "local_transcription_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+9
View File
@@ -0,0 +1,9 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
local_transcription_lib::run()
}
+42
View File
@@ -0,0 +1,42 @@
{
"productName": "Local Transcription",
"version": "1.4.0",
"identifier": "com.localtranscription.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"windows": [
{
"title": "Local Transcription",
"width": 800,
"height": 600,
"minWidth": 640,
"minHeight": 480,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico",
"icons/icon.png"
]
},
"plugins": {
"shell": {
"open": true
}
}
}
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import { onMount } from "svelte";
import Header from "$lib/components/Header.svelte";
import StatusBar from "$lib/components/StatusBar.svelte";
import Controls from "$lib/components/Controls.svelte";
import TranscriptionDisplay from "$lib/components/TranscriptionDisplay.svelte";
import Settings from "$lib/components/Settings.svelte";
import { backendStore } from "$lib/stores/backend";
import { configStore } from "$lib/stores/config";
let showSettings = $state(false);
let obsDisplayUrl = $derived(backendStore.obsUrl);
let syncDisplayUrl = $derived(backendStore.syncUrl);
function openSettings() {
showSettings = true;
}
function closeSettings() {
showSettings = false;
}
onMount(() => {
backendStore.connect();
configStore.loadConfig();
return () => {
backendStore.disconnect();
};
});
</script>
<div class="app-shell">
<Header onSettingsClick={openSettings} />
<StatusBar />
<div class="display-links">
<span class="link-label">OBS:</span>
<a href={obsDisplayUrl} target="_blank" rel="noopener">{obsDisplayUrl}</a>
{#if syncDisplayUrl}
<span class="link-separator">|</span>
<span class="link-label">Sync:</span>
<a href={syncDisplayUrl} target="_blank" rel="noopener"
>{syncDisplayUrl}</a
>
{/if}
</div>
<TranscriptionDisplay />
<Controls />
<div class="version-label">v{backendStore.version}</div>
</div>
{#if showSettings}
<Settings onClose={closeSettings} />
{/if}
<style>
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: var(--bg-primary);
}
.display-links {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 20px;
font-size: 12px;
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.link-label {
color: var(--text-secondary);
font-weight: 500;
}
.link-separator {
color: var(--text-muted);
margin: 0 4px;
}
.version-label {
position: fixed;
bottom: 6px;
right: 12px;
font-size: 11px;
color: var(--text-muted);
pointer-events: none;
z-index: 10;
}
</style>
+312
View File
@@ -0,0 +1,312 @@
/* Global dark theme styles for Local Transcription */
:root {
--bg-primary: #1e1e1e;
--bg-secondary: #2d2d2d;
--bg-tertiary: #3a3a3a;
--bg-hover: #454545;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-muted: #707070;
--accent-green: #4caf50;
--accent-green-hover: #45a049;
--accent-red: #f44336;
--accent-red-hover: #d32f2f;
--accent-blue: #2196f3;
--accent-blue-hover: #1976d2;
--accent-orange: #ff9800;
--border-color: #444;
--border-color-light: #555;
--scrollbar-track: #2d2d2d;
--scrollbar-thumb: #555;
--scrollbar-thumb-hover: #777;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
}
/* Buttons */
button {
font-family: inherit;
font-size: 13px;
font-weight: 500;
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease,
transform 0.1s ease;
user-select: none;
}
button:hover {
background-color: var(--bg-hover);
border-color: var(--border-color-light);
}
button:active {
transform: scale(0.98);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
button.primary {
background-color: var(--accent-green);
border-color: var(--accent-green);
color: white;
}
button.primary:hover {
background-color: var(--accent-green-hover);
}
button.danger {
background-color: var(--accent-red);
border-color: var(--accent-red);
color: white;
}
button.danger:hover {
background-color: var(--accent-red-hover);
}
/* Inputs and Selects */
input[type="text"],
input[type="password"],
input[type="number"],
input[type="url"],
input[type="email"],
select,
textarea {
font-family: inherit;
font-size: 13px;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--bg-secondary);
color: var(--text-primary);
outline: none;
transition: border-color 0.15s ease;
width: 100%;
}
input[type="text"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
input[type="url"]:focus,
input[type="email"]:focus,
select:focus,
textarea:focus {
border-color: var(--accent-blue);
}
input[type="text"]::placeholder,
input[type="password"]::placeholder,
input[type="url"]::placeholder {
color: var(--text-muted);
}
select {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23a0a0a0' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
}
/* Color input */
input[type="color"] {
width: 50px;
height: 36px;
border: 1px solid var(--border-color);
border-radius: 6px;
background-color: var(--bg-secondary);
cursor: pointer;
padding: 2px;
}
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 2px;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 3px;
}
/* Range slider */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-blue);
cursor: pointer;
border: 2px solid var(--bg-primary);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-blue);
cursor: pointer;
border: 2px solid var(--bg-primary);
}
/* Toggle / Checkbox styled as switch */
input[type="checkbox"] {
position: relative;
width: 40px;
height: 22px;
-webkit-appearance: none;
appearance: none;
background-color: var(--bg-tertiary);
border-radius: 11px;
cursor: pointer;
transition: background-color 0.2s ease;
flex-shrink: 0;
}
input[type="checkbox"]::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background-color: var(--text-secondary);
border-radius: 50%;
transition: transform 0.2s ease, background-color 0.2s ease;
}
input[type="checkbox"]:checked {
background-color: var(--accent-green);
}
input[type="checkbox"]:checked::after {
transform: translateX(18px);
background-color: white;
}
/* Radio buttons */
input[type="radio"] {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 50%;
background-color: var(--bg-secondary);
cursor: pointer;
position: relative;
flex-shrink: 0;
}
input[type="radio"]:checked {
border-color: var(--accent-blue);
}
input[type="radio"]:checked::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 8px;
height: 8px;
background-color: var(--accent-blue);
border-radius: 50%;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
/* Links */
a {
color: var(--accent-blue);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Label */
label {
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
+6
View File
@@ -0,0 +1,6 @@
import App from "./App.svelte";
import { mount } from "svelte";
import "./app.css";
const app = mount(App, { target: document.getElementById("app")! });
export default app;
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte()],
clearScreen: false,
resolve: {
alias: {
$lib: path.resolve("./src/lib"),
},
},
server: {
port: 1420,
strictPort: true,
watch: {
ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"],
},
},
});