Compare commits
32 Commits
v1.4.18
...
sidecar-v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
375669f657 | ||
|
|
c8b11fb0ad | ||
|
|
273a926f03 | ||
|
|
5bbbc38875 | ||
|
|
d50be6654d | ||
|
|
68abf49018 | ||
|
|
8cc2a3ec7a | ||
|
|
8aa9dfc644 | ||
|
|
3f16aa838d | ||
|
|
3d3d7ec3c5 | ||
|
|
bb039399fc | ||
|
|
9dcb14e92c | ||
|
|
8db9b8298b | ||
|
|
411779f578 | ||
|
|
bc6055a707 | ||
|
|
e42a922507 | ||
|
|
8fc2d11c5f | ||
|
|
11832e911b | ||
|
|
18e6b974c0 | ||
|
|
08e464daaf | ||
|
|
5d22adcaa4 | ||
|
|
36b4f7dad5 | ||
|
|
1ecb23b83f | ||
|
|
4b88871a9b | ||
|
|
0ae48a67d5 | ||
|
|
924cae6c75 | ||
|
|
5139936e18 | ||
|
|
47724f1ac0 | ||
|
|
3b204be37e | ||
|
|
4c02a48135 | ||
|
|
997e97c19a | ||
|
|
6ca8fc41b2 |
@@ -13,23 +13,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
- name: Show tag
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
fi
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | head -1 | sed 's|.*refs/tags/||')
|
||||
fi
|
||||
echo "Building for tag: ${TAG}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Building for tag: ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -58,7 +50,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get install -y jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
echo "Release tag: ${TAG}"
|
||||
|
||||
echo "Waiting for release ${TAG} to be available..."
|
||||
|
||||
@@ -13,23 +13,15 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
- name: Show tag
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
fi
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | head -1 | sed 's|.*refs/tags/||')
|
||||
fi
|
||||
echo "Building for tag: ${TAG}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Building for tag: ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -56,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
which jq || brew install jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
echo "Release tag: ${TAG}"
|
||||
|
||||
echo "Waiting for release ${TAG} to be available..."
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Build App (Windows)
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
shell: powershell
|
||||
|
||||
229
.gitea/workflows/build-sidecar-cloud.yml
Normal file
229
.gitea/workflows/build-sidecar-cloud.yml
Normal file
@@ -0,0 +1,229 @@
|
||||
name: Build Sidecar (Cloud)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.5)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-cloud-linux:
|
||||
name: Build Cloud Sidecar (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
run: |
|
||||
echo "Building cloud sidecar for tag ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- 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 cloud sidecar
|
||||
env:
|
||||
UV_NO_SOURCES: "1"
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
|
||||
.venv/bin/pyinstaller local-transcription-cloud.spec
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cloud.zip .
|
||||
|
||||
- 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="${RELEASE_TAG}"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
|
||||
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
|
||||
echo "Found release ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${i}/30: waiting for release..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Release not found"; exit 1
|
||||
fi
|
||||
|
||||
for file in sidecar-*-cloud.zip; do
|
||||
filename=$(basename "$file")
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
[ -n "${ASSET_ID}" ] && curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" "${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
curl -s -o /dev/null -w "Upload ${filename}: HTTP %{http_code}\n" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" -H "Content-Type: application/octet-stream" \
|
||||
-T "$file" "${REPO_API}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
build-cloud-windows:
|
||||
name: Build Cloud Sidecar (Windows)
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
shell: powershell
|
||||
run: Write-Host "Building cloud sidecar for tag $env:RELEASE_TAG"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
shell: powershell
|
||||
run: |
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
Write-Host "uv already installed"
|
||||
} else {
|
||||
irm https://astral.sh/uv/install.ps1 | iex
|
||||
$uvPaths = @("$env:USERPROFILE\.local\bin", "$env:USERPROFILE\.cargo\bin", "$env:LOCALAPPDATA\uv\bin")
|
||||
foreach ($p in $uvPaths) { if (Test-Path $p) { echo $p | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append } }
|
||||
}
|
||||
|
||||
- name: Set up Python
|
||||
shell: powershell
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Build cloud sidecar
|
||||
shell: powershell
|
||||
env:
|
||||
UV_NO_SOURCES: "1"
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
|
||||
.venv\Scripts\pyinstaller.exe local-transcription-cloud.spec
|
||||
|
||||
- name: Package
|
||||
shell: powershell
|
||||
run: |
|
||||
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) { choco install 7zip -y }
|
||||
7z a -tzip -mx=5 sidecar-windows-x86_64-cloud.zip .\dist\local-transcription-backend\*
|
||||
|
||||
- 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 = $env:RELEASE_TAG
|
||||
|
||||
$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 release $TAG (ID: $RELEASE_ID)"; break }
|
||||
} catch {}
|
||||
Write-Host "Attempt ${i}/30: waiting..."; Start-Sleep -Seconds 10
|
||||
}
|
||||
if (-not $RELEASE_ID) { Write-Host "ERROR: Release not found"; exit 1 }
|
||||
|
||||
Get-ChildItem -Path . -Filter "sidecar-*-cloud.zip" | ForEach-Object {
|
||||
$fn = $_.Name; $enc = [System.Uri]::EscapeDataString($fn)
|
||||
try {
|
||||
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
|
||||
$existing = $assets | Where-Object { $_.name -eq $fn }
|
||||
if ($existing) { Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers }
|
||||
} catch {}
|
||||
curl.exe --fail -s -X POST -H "Authorization: token $env:BUILD_TOKEN" -H "Content-Type: application/octet-stream" -T "$($_.FullName)" "$REPO_API/releases/$RELEASE_ID/assets?name=$enc"
|
||||
Write-Host "Uploaded $fn"
|
||||
}
|
||||
|
||||
build-cloud-macos:
|
||||
name: Build Cloud Sidecar (macOS)
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
run: |
|
||||
echo "Building cloud sidecar for tag ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: brew install portaudio
|
||||
|
||||
- name: Build cloud sidecar
|
||||
env:
|
||||
UV_NO_SOURCES: "1"
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
|
||||
.venv/bin/pyinstaller local-transcription-cloud.spec
|
||||
|
||||
- name: Package
|
||||
run: |
|
||||
cd dist/local-transcription-backend && zip -r ../../sidecar-macos-aarch64-cloud.zip .
|
||||
|
||||
- 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="${RELEASE_TAG}"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
|
||||
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
|
||||
echo "Found release ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${i}/30: waiting for release..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Release not found"; exit 1
|
||||
fi
|
||||
|
||||
for file in sidecar-*-cloud.zip; do
|
||||
filename=$(basename "$file")
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
[ -n "${ASSET_ID}" ] && curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" "${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
curl -s -o /dev/null -w "Upload ${filename}: HTTP %{http_code}\n" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" -H "Content-Type: application/octet-stream" \
|
||||
-T "$file" "${REPO_API}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
@@ -13,23 +13,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
- name: Show tag
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
fi
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/sidecar-v*' | head -1 | sed 's|.*refs/tags/||')
|
||||
fi
|
||||
echo "Building for tag: ${TAG}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Building for tag: ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
@@ -75,7 +67,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get install -y jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
|
||||
echo "Waiting for sidecar release ${TAG} to be available..."
|
||||
for i in $(seq 1 30); do
|
||||
|
||||
@@ -13,23 +13,15 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
- name: Show tag
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
fi
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/sidecar-v*' | head -1 | sed 's|.*refs/tags/||')
|
||||
fi
|
||||
echo "Building for tag: ${TAG}"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "Building for tag: ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ steps.tag.outputs.tag }}
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
@@ -66,7 +58,7 @@ jobs:
|
||||
run: |
|
||||
which jq || brew install jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
|
||||
echo "Waiting for sidecar release ${TAG} to be available..."
|
||||
for i in $(seq 1 30); do
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
shell: powershell
|
||||
|
||||
@@ -1,13 +1,40 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install npm deps
|
||||
run: npm ci
|
||||
|
||||
- name: Frontend tests
|
||||
run: npx vitest run
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Python tests
|
||||
run: |
|
||||
uv venv .testvenv
|
||||
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
|
||||
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
|
||||
|
||||
bump-version:
|
||||
name: Bump version and tag
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.bump.outputs.new_version }}
|
||||
@@ -82,10 +109,14 @@ jobs:
|
||||
|
||||
for workflow in build-app-linux.yml build-app-windows.yml build-app-macos.yml; do
|
||||
echo "Dispatching ${workflow} for ${TAG}..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/dispatch_resp.txt -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"tag\": \"${TAG}\"}}" \
|
||||
"${REPO_API}/actions/workflows/${workflow}/dispatches")
|
||||
echo " -> HTTP ${HTTP_CODE}"
|
||||
[ "$HTTP_CODE" != "204" ] && cat /tmp/dispatch_resp.txt && echo ""
|
||||
done
|
||||
|
||||
# NOTE: Automatic cleanup disabled -- it races with async builds.
|
||||
# Clean up old releases manually from the Gitea UI when needed.
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
name: Sidecar Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'client/**'
|
||||
- 'server/**'
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'local-transcription-headless.spec'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Python tests
|
||||
run: |
|
||||
uv venv .testvenv
|
||||
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
|
||||
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
|
||||
|
||||
bump-sidecar-version:
|
||||
name: Bump sidecar version and tag
|
||||
needs: test
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
@@ -62,8 +73,6 @@ jobs:
|
||||
echo "New sidecar version: ${NEW_VERSION}"
|
||||
|
||||
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
|
||||
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
|
||||
@@ -75,7 +84,7 @@ jobs:
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.bump.outputs.version }}"
|
||||
TAG="${{ steps.bump.outputs.tag }}"
|
||||
git add pyproject.toml version.py
|
||||
git add pyproject.toml
|
||||
git commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]"
|
||||
git tag "${TAG}"
|
||||
|
||||
@@ -109,7 +118,7 @@ jobs:
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ steps.bump.outputs.tag }}"
|
||||
|
||||
for workflow in build-sidecar-linux.yml build-sidecar-windows.yml build-sidecar-macos.yml; do
|
||||
for workflow in build-sidecar-linux.yml build-sidecar-windows.yml build-sidecar-macos.yml build-sidecar-cloud.yml; do
|
||||
echo "Dispatching ${workflow} for ${TAG}..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
@@ -118,3 +127,6 @@ jobs:
|
||||
"${REPO_API}/actions/workflows/${workflow}/dispatches")
|
||||
echo " -> HTTP ${HTTP_CODE}"
|
||||
done
|
||||
|
||||
# NOTE: Automatic cleanup disabled -- it races with async builds.
|
||||
# Clean up old releases manually from the Gitea UI when needed.
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
python-tests:
|
||||
@@ -13,12 +14,20 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install test dependencies
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install --break-system-packages pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
|
||||
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: Run pytest
|
||||
run: python3 -m pytest backend/tests/ client/tests/ -v --tb=short
|
||||
run: |
|
||||
uv venv .testvenv
|
||||
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
|
||||
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend Tests
|
||||
|
||||
@@ -18,13 +18,18 @@ import sys
|
||||
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.models import TranscriptionResult
|
||||
from client.deepgram_transcription import DeepgramTranscriptionEngine
|
||||
from client.server_sync import ServerSyncClient
|
||||
from server.web_display import TranscriptionWebServer
|
||||
from version import __version__
|
||||
|
||||
# Heavy imports (torch, RealtimeSTT, faster-whisper) are deferred so
|
||||
# the cloud-only sidecar build can exclude them entirely.
|
||||
# Imported lazily in _initialize_engine() when remote.mode == "local".
|
||||
RealtimeTranscriptionEngine = None
|
||||
DeviceManager = None
|
||||
|
||||
|
||||
class AppState:
|
||||
"""Enum-like class for application states."""
|
||||
@@ -89,7 +94,18 @@ class AppController:
|
||||
|
||||
def __init__(self, config: Optional[Config] = None):
|
||||
self.config = config or Config()
|
||||
self.device_manager = DeviceManager()
|
||||
|
||||
# DeviceManager is only needed for local Whisper mode.
|
||||
# Lazy-import to keep the cloud-only sidecar lightweight.
|
||||
global DeviceManager
|
||||
if DeviceManager is None:
|
||||
try:
|
||||
from client.device_utils import DeviceManager as _DM
|
||||
DeviceManager = _DM
|
||||
except ImportError:
|
||||
DeviceManager = None
|
||||
|
||||
self.device_manager = DeviceManager() if DeviceManager else None
|
||||
|
||||
# State
|
||||
self._state = AppState.INITIALIZING
|
||||
@@ -243,15 +259,12 @@ class AppController:
|
||||
|
||||
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()
|
||||
device_config = self.config.get('transcription.device', 'auto')
|
||||
compute_type = self.config.get('transcription.compute_type', 'default')
|
||||
|
||||
self.current_model_size = model
|
||||
@@ -284,6 +297,18 @@ class AppController:
|
||||
self.transcription_engine.set_error_callback(self._on_remote_error)
|
||||
self.transcription_engine.set_credits_low_callback(self._on_credits_low)
|
||||
else:
|
||||
# Lazy-import heavy local transcription dependencies
|
||||
global RealtimeTranscriptionEngine
|
||||
if RealtimeTranscriptionEngine is None:
|
||||
from client.transcription_engine_realtime import RealtimeTranscriptionEngine as _RTE
|
||||
RealtimeTranscriptionEngine = _RTE
|
||||
|
||||
if self.device_manager:
|
||||
self.device_manager.set_device(device_config)
|
||||
device = self.device_manager.get_device_for_whisper()
|
||||
else:
|
||||
device = "cpu"
|
||||
|
||||
self.transcription_engine = RealtimeTranscriptionEngine(
|
||||
model=model,
|
||||
device=device,
|
||||
@@ -577,12 +602,18 @@ class AppController:
|
||||
if self.config.get('server_sync.enabled', False):
|
||||
self._start_server_sync()
|
||||
|
||||
# Check if model/device changed
|
||||
# Check if model/device/remote mode changed -- any of these require
|
||||
# a full engine reload since they change which engine class is used
|
||||
new_model = self.config.get('transcription.model', 'base.en')
|
||||
new_device = self.config.get('transcription.device', 'auto')
|
||||
new_remote_mode = self.config.get('remote.mode', 'local')
|
||||
current_remote_mode = 'local'
|
||||
if self.transcription_engine:
|
||||
current_remote_mode = getattr(self.transcription_engine, 'mode', 'local')
|
||||
engine_reload_needed = (
|
||||
self.current_model_size != new_model
|
||||
or self.current_device_config != new_device
|
||||
or current_remote_mode != new_remote_mode
|
||||
)
|
||||
|
||||
if engine_reload_needed:
|
||||
@@ -596,7 +627,7 @@ class AppController:
|
||||
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()
|
||||
device_info = self.device_manager.get_device_info() if self.device_manager else []
|
||||
|
||||
remote_mode = self.config.get('remote.mode', 'local')
|
||||
if remote_mode in ('managed', 'byok') and self.transcription_engine:
|
||||
@@ -640,10 +671,13 @@ class AppController:
|
||||
|
||||
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})
|
||||
if self.device_manager:
|
||||
device_info = self.device_manager.get_device_info()
|
||||
for dev_id, dev_name in device_info:
|
||||
devices.append({"id": dev_id, "name": dev_name})
|
||||
else:
|
||||
devices.append({"id": "cloud", "name": "Cloud (Deepgram)"})
|
||||
return devices
|
||||
|
||||
# ── Update Checking ────────────────────────────────────────────
|
||||
|
||||
@@ -79,7 +79,7 @@ async def test_start_when_not_ready(api_client, controller):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(api_client, controller):
|
||||
from client.transcription_engine_realtime import TranscriptionResult
|
||||
from client.models import TranscriptionResult
|
||||
from datetime import datetime
|
||||
|
||||
controller.transcriptions = [
|
||||
|
||||
@@ -72,7 +72,7 @@ def test_double_start_rejected(controller):
|
||||
|
||||
def test_clear_transcriptions(controller):
|
||||
"""clear_transcriptions should empty the list and return the count."""
|
||||
from client.transcription_engine_realtime import TranscriptionResult
|
||||
from client.models import TranscriptionResult
|
||||
|
||||
controller.transcriptions = [
|
||||
TranscriptionResult(text="Hello", is_final=True, timestamp=datetime.now(), user_name="Alice"),
|
||||
@@ -85,7 +85,7 @@ def test_clear_transcriptions(controller):
|
||||
|
||||
def test_get_transcriptions_text_with_timestamps(controller):
|
||||
"""get_transcriptions_text should include [HH:MM:SS] prefixes when requested."""
|
||||
from client.transcription_engine_realtime import TranscriptionResult
|
||||
from client.models import TranscriptionResult
|
||||
|
||||
ts = datetime(2025, 1, 15, 10, 30, 45)
|
||||
controller.transcriptions = [
|
||||
@@ -141,7 +141,7 @@ def test_apply_settings_no_reload_when_same(controller):
|
||||
|
||||
def test_on_final_transcription_callback_fires(controller):
|
||||
"""_on_final_transcription should append and invoke on_transcription callback."""
|
||||
from client.transcription_engine_realtime import TranscriptionResult
|
||||
from client.models import TranscriptionResult
|
||||
|
||||
received = []
|
||||
controller.on_transcription = lambda data: received.append(data)
|
||||
@@ -166,7 +166,7 @@ def test_on_final_transcription_callback_fires(controller):
|
||||
|
||||
def test_on_final_transcription_ignored_when_not_transcribing(controller):
|
||||
"""If the controller is not in transcribing state the callback should be a no-op."""
|
||||
from client.transcription_engine_realtime import TranscriptionResult
|
||||
from client.models import TranscriptionResult
|
||||
|
||||
controller.is_transcribing = False
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from datetime import datetime
|
||||
from queue import Queue, Empty
|
||||
from typing import Optional, Callable
|
||||
|
||||
from client.transcription_engine_realtime import TranscriptionResult
|
||||
from client.models import TranscriptionResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,7 +67,7 @@ class DeepgramTranscriptionEngine:
|
||||
# Audio parameters
|
||||
self.sample_rate: int = 16000
|
||||
self.channels: int = 1
|
||||
self.blocksize: int = 4096
|
||||
self.blocksize: int = 1024 # ~64ms chunks for lower latency streaming
|
||||
|
||||
# Callbacks
|
||||
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
|
||||
@@ -314,6 +314,8 @@ class DeepgramTranscriptionEngine:
|
||||
f"model={self.deepgram_model}"
|
||||
f"&language={self.language}"
|
||||
"&interim_results=true"
|
||||
"&punctuate=true"
|
||||
"&smart_format=true"
|
||||
"&encoding=linear16"
|
||||
f"&sample_rate={self.sample_rate}"
|
||||
f"&channels={self.channels}"
|
||||
@@ -370,10 +372,16 @@ class DeepgramTranscriptionEngine:
|
||||
|
||||
async def _send_loop(self):
|
||||
"""Drain the audio queue and push raw PCM bytes over the WebSocket."""
|
||||
loop = asyncio.get_event_loop()
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
pcm_bytes = self._audio_queue.get(timeout=0.1)
|
||||
except Empty:
|
||||
# Use run_in_executor to avoid blocking the async event loop
|
||||
# (which would stall the receive loop and delay transcriptions)
|
||||
pcm_bytes = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, lambda: self._audio_queue.get(timeout=0.5)),
|
||||
timeout=1.0,
|
||||
)
|
||||
except (Empty, asyncio.TimeoutError):
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
29
client/models.py
Normal file
29
client/models.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Shared data models used across transcription engines."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TranscriptionResult:
|
||||
"""Represents a transcription result."""
|
||||
|
||||
def __init__(self, text: str, is_final: bool, timestamp: datetime, user_name: str = ""):
|
||||
"""
|
||||
Initialize transcription result.
|
||||
|
||||
Args:
|
||||
text: Transcribed text
|
||||
is_final: Whether this is a final transcription or realtime preview
|
||||
timestamp: Timestamp of transcription
|
||||
user_name: Name of the user/speaker
|
||||
"""
|
||||
self.text = text.strip()
|
||||
self.is_final = is_final
|
||||
self.timestamp = timestamp
|
||||
self.user_name = user_name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
time_str = self.timestamp.strftime("%H:%M:%S")
|
||||
prefix = "[FINAL]" if self.is_final else "[PREVIEW]"
|
||||
if self.user_name and self.user_name.strip():
|
||||
return f"{prefix} [{time_str}] {self.user_name}: {self.text}"
|
||||
return f"{prefix} [{time_str}] {self.text}"
|
||||
@@ -8,30 +8,8 @@ from threading import Lock
|
||||
import logging
|
||||
|
||||
|
||||
class TranscriptionResult:
|
||||
"""Represents a transcription result."""
|
||||
|
||||
def __init__(self, text: str, is_final: bool, timestamp: datetime, user_name: str = ""):
|
||||
"""
|
||||
Initialize transcription result.
|
||||
|
||||
Args:
|
||||
text: Transcribed text
|
||||
is_final: Whether this is a final transcription or realtime preview
|
||||
timestamp: Timestamp of transcription
|
||||
user_name: Name of the user/speaker
|
||||
"""
|
||||
self.text = text.strip()
|
||||
self.is_final = is_final
|
||||
self.timestamp = timestamp
|
||||
self.user_name = user_name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
time_str = self.timestamp.strftime("%H:%M:%S")
|
||||
prefix = "[FINAL]" if self.is_final else "[PREVIEW]"
|
||||
if self.user_name and self.user_name.strip():
|
||||
return f"{prefix} [{time_str}] {self.user_name}: {self.text}"
|
||||
return f"{prefix} [{time_str}] {self.text}"
|
||||
# Re-export TranscriptionResult from the shared models module for backward compatibility
|
||||
from client.models import TranscriptionResult # noqa: F401
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
|
||||
152
local-transcription-cloud.spec
Normal file
152
local-transcription-cloud.spec
Normal file
@@ -0,0 +1,152 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller spec file for cloud-only Local Transcription backend.
|
||||
|
||||
This builds a lightweight sidecar (~50MB) that only supports Deepgram
|
||||
cloud transcription (managed + BYOK). No local Whisper models, no
|
||||
PyTorch, no CUDA -- just audio capture and WebSocket streaming.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
block_cipher = None
|
||||
is_windows = sys.platform == 'win32'
|
||||
|
||||
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
|
||||
|
||||
# Data files
|
||||
datas = [
|
||||
('config/default_config.yaml', 'config'),
|
||||
]
|
||||
|
||||
# Hidden imports -- only lightweight deps needed for Deepgram streaming
|
||||
hiddenimports = [
|
||||
'sounddevice',
|
||||
'numpy',
|
||||
# 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 cloud backend packages...")
|
||||
for package in ['fastapi', 'starlette', 'pydantic', 'pydantic_core', 'anyio', 'uvicorn', 'websockets', 'h11']:
|
||||
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']:
|
||||
try:
|
||||
data_files = collect_data_files(package)
|
||||
if data_files:
|
||||
datas += data_files
|
||||
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=[
|
||||
# Exclude all heavy ML/local transcription deps
|
||||
'torch', 'torchaudio', 'torchvision',
|
||||
'faster_whisper', 'ctranslate2',
|
||||
'RealtimeSTT', 'webrtcvad', 'webrtcvad_wheels',
|
||||
'silero_vad', 'onnxruntime',
|
||||
'openwakeword', 'pvporcupine', 'pyaudio',
|
||||
'noisereduce', 'scipy',
|
||||
# Exclude GUI frameworks
|
||||
'PySide6', 'PyQt5', 'PyQt6', 'tkinter',
|
||||
# Exclude other unnecessary heavy packages
|
||||
'matplotlib', 'PIL', 'cv2',
|
||||
],
|
||||
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,
|
||||
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',
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "local-transcription",
|
||||
"private": true,
|
||||
"version": "1.4.18",
|
||||
"version": "2.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "local-transcription"
|
||||
version = "1.0.3"
|
||||
version = "1.0.5"
|
||||
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -1881,7 +1881,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "local-transcription"
|
||||
version = "1.4.16"
|
||||
version = "2.0.3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "local-transcription"
|
||||
version = "1.4.18"
|
||||
version = "2.0.6"
|
||||
description = "Real-time speech-to-text transcription for streamers"
|
||||
authors = ["Local Transcription Contributors"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1 +1 @@
|
||||
{}
|
||||
{"default":{"identifier":"default","description":"Default permissions for the main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","shell:default","dialog:default","process:default"]}}
|
||||
@@ -29,9 +29,9 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.manage(sidecar::ManagedSidecar(Mutex::new(
|
||||
.manage(sidecar::ManagedSidecar(std::sync::Arc::new(Mutex::new(
|
||||
sidecar::SidecarManager::new(),
|
||||
)))
|
||||
))))
|
||||
.setup(|app| {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
@@ -68,6 +68,7 @@ pub fn run() {
|
||||
sidecar::get_sidecar_port,
|
||||
sidecar::start_sidecar,
|
||||
sidecar::stop_sidecar,
|
||||
sidecar::reset_sidecar,
|
||||
write_log,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
@@ -54,7 +54,8 @@ fn read_installed_version() -> Option<String> {
|
||||
}
|
||||
|
||||
fn sidecar_dir_for_version(version: &str) -> PathBuf {
|
||||
data_dir().join(format!("sidecar-{version}"))
|
||||
// version is the full tag name, e.g. "sidecar-v1.0.3" -- use it directly
|
||||
data_dir().join(version)
|
||||
}
|
||||
|
||||
fn binary_path_for_version(version: &str) -> PathBuf {
|
||||
@@ -371,12 +372,12 @@ fn extract_zip(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<(),
|
||||
|
||||
fn cleanup_old_versions(current_version: &str) {
|
||||
let data = data_dir();
|
||||
let current_dir_name = format!("sidecar-{current_version}");
|
||||
// current_version is already the full tag, e.g. "sidecar-v1.0.3"
|
||||
if let Ok(entries) = std::fs::read_dir(data) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("sidecar-v") // e.g. sidecar-v1.0.1
|
||||
&& name != current_dir_name
|
||||
if name.starts_with("sidecar-")
|
||||
&& name != current_version
|
||||
&& entry.path().is_dir()
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
@@ -433,6 +434,20 @@ impl SidecarManager {
|
||||
.ok_or_else(|| "Sidecar running but port unknown".into());
|
||||
}
|
||||
|
||||
// Clear stale PID lock from a previous crash so the sidecar can start.
|
||||
// The Python InstanceLock writes to ~/.local-transcription/app.lock
|
||||
if let Ok(home) = std::env::var("USERPROFILE")
|
||||
.or_else(|_| std::env::var("HOME"))
|
||||
{
|
||||
let lock_file = PathBuf::from(home)
|
||||
.join(".local-transcription")
|
||||
.join("app.lock");
|
||||
if lock_file.exists() {
|
||||
eprintln!("[sidecar] Removing stale lock file: {}", lock_file.display());
|
||||
let _ = std::fs::remove_file(&lock_file);
|
||||
}
|
||||
}
|
||||
|
||||
let is_dev = cfg!(debug_assertions)
|
||||
|| std::env::var("LOCAL_TRANSCRIPTION_DEV")
|
||||
.map(|v| v == "1")
|
||||
@@ -463,11 +478,63 @@ impl SidecarManager {
|
||||
.take()
|
||||
.ok_or("Failed to capture sidecar stdout")?;
|
||||
|
||||
let port = Self::wait_for_ready(stdout)?;
|
||||
// Capture stderr in a background thread so we can log it
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or("Failed to capture sidecar stderr")?;
|
||||
|
||||
self.child = Some(child);
|
||||
self.port = Some(port);
|
||||
Ok(port)
|
||||
let log_dir = DIRS.get().map(|d| d.data_dir.clone());
|
||||
std::thread::spawn(move || {
|
||||
use std::io::BufRead;
|
||||
let reader = std::io::BufReader::new(stderr);
|
||||
let mut log_file = log_dir.and_then(|d| {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(d.join("sidecar.log"))
|
||||
.ok()
|
||||
});
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
eprintln!("[sidecar-stderr] {}", line);
|
||||
if let Some(ref mut f) = log_file {
|
||||
use std::io::Write;
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
match Self::wait_for_ready(stdout) {
|
||||
Ok(port) => {
|
||||
self.child = Some(child);
|
||||
self.port = Some(port);
|
||||
Ok(port)
|
||||
}
|
||||
Err(e) => {
|
||||
// Kill the child if ready failed
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
// Read the sidecar.log for context
|
||||
let log_hint = DIRS
|
||||
.get()
|
||||
.and_then(|d| std::fs::read_to_string(d.data_dir.join("sidecar.log")).ok())
|
||||
.and_then(|s| {
|
||||
let lines: Vec<&str> = s.lines().collect();
|
||||
let tail: Vec<&str> = lines.iter().rev().take(10).rev().cloned().collect();
|
||||
if tail.is_empty() { None } else { Some(tail.join("\n")) }
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if log_hint.is_empty() {
|
||||
Err(e)
|
||||
} else {
|
||||
Err(format!("{e}\n\nSidecar stderr (last 10 lines):\n{log_hint}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the sidecar process if running.
|
||||
@@ -487,20 +554,30 @@ impl SidecarManager {
|
||||
// -- private helpers -------------------------------------------------------
|
||||
|
||||
fn build_dev_command(&self) -> Result<std::process::Command, String> {
|
||||
let mut cmd = std::process::Command::new("python");
|
||||
cmd.args(["-m", "backend.main_headless"]);
|
||||
// Use `uv run` to ensure we use the project's venv, not system Python
|
||||
let mut cmd = std::process::Command::new("uv");
|
||||
cmd.args(["run", "python", "-u", "-m", "backend.main_headless"]);
|
||||
|
||||
// Try to find the project root (parent of src-tauri)
|
||||
if let Some(dirs) = DIRS.get() {
|
||||
let project_root = dirs
|
||||
.resource_dir
|
||||
.parent() // src-tauri
|
||||
.and_then(|p| p.parent()); // project root
|
||||
if let Some(root) = project_root {
|
||||
cmd.current_dir(root);
|
||||
}
|
||||
// Find the project root: try CARGO_MANIFEST_DIR first (set at compile time),
|
||||
// then fall back to resource_dir parent chain
|
||||
let manifest_dir = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from);
|
||||
let project_root = manifest_dir
|
||||
.as_ref()
|
||||
.and_then(|d| d.parent()) // src-tauri -> project root
|
||||
.or_else(|| {
|
||||
DIRS.get()
|
||||
.and_then(|d| d.resource_dir.parent())
|
||||
.and_then(|p| p.parent())
|
||||
});
|
||||
|
||||
if let Some(root) = project_root {
|
||||
eprintln!("[sidecar] Dev mode: working dir = {}", root.display());
|
||||
cmd.current_dir(root);
|
||||
} else {
|
||||
eprintln!("[sidecar] Dev mode: WARNING - could not determine project root");
|
||||
}
|
||||
|
||||
cmd.env("PYTHONUNBUFFERED", "1");
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
@@ -516,27 +593,51 @@ impl SidecarManager {
|
||||
bin.parent()
|
||||
.ok_or("Cannot determine sidecar parent dir")?,
|
||||
);
|
||||
// Force unbuffered stdout so the ready event is sent immediately.
|
||||
// PyInstaller frozen executables buffer stdout when piped.
|
||||
cmd.env("PYTHONUNBUFFERED", "1");
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
fn wait_for_ready(stdout: std::process::ChildStdout) -> Result<u16, String> {
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let timeout = std::time::Duration::from_secs(120);
|
||||
let start = std::time::Instant::now();
|
||||
use std::sync::mpsc;
|
||||
|
||||
for line in reader.lines() {
|
||||
if start.elapsed() > timeout {
|
||||
return Err("Timed out waiting for sidecar ready event".into());
|
||||
}
|
||||
let line = line.map_err(|e| format!("IO error reading stdout: {e}"))?;
|
||||
if let Ok(evt) = serde_json::from_str::<ReadyEvent>(&line) {
|
||||
if evt.event == "ready" {
|
||||
return Ok(evt.port);
|
||||
let timeout = std::time::Duration::from_secs(120);
|
||||
|
||||
// Read stdout in a background thread so we can enforce a real timeout.
|
||||
// BufReader::lines() blocks indefinitely if no data arrives.
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line) => {
|
||||
eprintln!("[sidecar-stdout] {}", line);
|
||||
if let Ok(evt) = serde_json::from_str::<ReadyEvent>(&line) {
|
||||
if evt.event == "ready" {
|
||||
let _ = tx.send(Ok(evt.port));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(format!("IO error reading stdout: {e}")));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore other lines (e.g. log output)
|
||||
}
|
||||
Err("Sidecar process exited before sending ready event".into())
|
||||
let _ = tx.send(Err(
|
||||
"Sidecar process exited before sending ready event".into(),
|
||||
));
|
||||
});
|
||||
|
||||
rx.recv_timeout(timeout).unwrap_or_else(|_| {
|
||||
Err(format!(
|
||||
"Timed out after {}s waiting for sidecar ready event",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,7 +646,8 @@ impl SidecarManager {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wrapper so we can store `SidecarManager` in Tauri's managed state.
|
||||
pub struct ManagedSidecar(pub Mutex<SidecarManager>);
|
||||
/// Uses Arc so it can be cloned into background threads for async commands.
|
||||
pub struct ManagedSidecar(pub std::sync::Arc<Mutex<SidecarManager>>);
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Option<u16>, String> {
|
||||
@@ -561,12 +663,16 @@ pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Optio
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
|
||||
let mut mgr = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {e}"))?;
|
||||
mgr.ensure_running()
|
||||
pub async fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
|
||||
let mgr = state.0.clone();
|
||||
// Run blocking sidecar launch in a background thread so it doesn't
|
||||
// freeze the Tauri UI while waiting for the ready event (up to 120s).
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut mgr = mgr.lock().map_err(|e| format!("Lock error: {e}"))?;
|
||||
mgr.ensure_running()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -579,6 +685,42 @@ pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the running sidecar, delete its files and version marker.
|
||||
/// The next app launch will show the sidecar download prompt.
|
||||
#[tauri::command]
|
||||
pub fn reset_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), String> {
|
||||
// Stop the running sidecar first
|
||||
{
|
||||
let mut mgr = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {e}"))?;
|
||||
mgr.stop();
|
||||
}
|
||||
|
||||
let data = data_dir();
|
||||
|
||||
// Delete the version file so check_sidecar returns false
|
||||
let vf = version_file();
|
||||
if vf.exists() {
|
||||
std::fs::remove_file(&vf)
|
||||
.map_err(|e| format!("Failed to delete version file: {e}"))?;
|
||||
}
|
||||
|
||||
// Delete all sidecar directories
|
||||
if let Ok(entries) = std::fs::read_dir(&data) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("sidecar-") && entry.path().is_dir() {
|
||||
eprintln!("[sidecar] Removing {}", entry.path().display());
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -739,7 +881,7 @@ mod tests {
|
||||
fn sidecar_dir_for_version_contains_version() {
|
||||
let data = ensure_dirs_initialised();
|
||||
let dir = sidecar_dir_for_version("sidecar-v1.2.3");
|
||||
assert_eq!(dir, data.join("sidecar-sidecar-v1.2.3"));
|
||||
assert_eq!(dir, data.join("sidecar-v1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -784,9 +926,8 @@ mod tests {
|
||||
std::fs::create_dir_all(data.join(d)).unwrap();
|
||||
}
|
||||
|
||||
// cleanup_old_versions builds `current_dir_name = "sidecar-{version}"`.
|
||||
// Passing "v1.0.2" produces "sidecar-v1.0.2" which matches our dir name.
|
||||
cleanup_old_versions("v1.0.2");
|
||||
// current_version is the full tag, e.g. "sidecar-v1.0.2"
|
||||
cleanup_old_versions("sidecar-v1.0.2");
|
||||
|
||||
assert!(
|
||||
!data.join("sidecar-v1.0.0").exists(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Local Transcription",
|
||||
"version": "1.4.18",
|
||||
"version": "2.0.6",
|
||||
"identifier": "net.anhonesthost.local-transcription",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
import { backendStore } from "$lib/stores/backend";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
type SidecarState = "checking" | "needs_setup" | "starting" | "connected";
|
||||
type SidecarState = "checking" | "needs_setup" | "update_available" | "starting" | "connected";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let sidecarState = $state<SidecarState>("checking");
|
||||
let debugLog = $state("");
|
||||
let availableUpdate = $state("");
|
||||
|
||||
let obsDisplayUrl = $derived(backendStore.obsUrl);
|
||||
let syncDisplayUrl = $derived(backendStore.syncUrl);
|
||||
@@ -53,6 +54,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for sidecar updates before launching
|
||||
try {
|
||||
log("Checking for sidecar updates...");
|
||||
const update = await invoke<string | null>("check_sidecar_update");
|
||||
if (update) {
|
||||
log(`Sidecar update available: ${update}`);
|
||||
availableUpdate = update;
|
||||
sidecarState = "update_available";
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Update check failed (non-fatal): ${err}`);
|
||||
}
|
||||
|
||||
await launchSidecar();
|
||||
} catch (err) {
|
||||
// Not running in Tauri (browser dev mode) - skip sidecar check
|
||||
@@ -118,6 +133,26 @@
|
||||
{:else if sidecarState === "needs_setup"}
|
||||
<SidecarSetup onComplete={onSidecarReady} />
|
||||
|
||||
{:else if sidecarState === "update_available"}
|
||||
<div class="connecting-overlay" style="background:#1e1e1e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100%;width:100%;">
|
||||
<div class="connecting-content" style="text-align:center;max-width:400px;">
|
||||
<h2 style="font-size:20px;margin:0 0 12px;">Sidecar Update Available</h2>
|
||||
<p style="color:#a0a0a0;font-size:14px;margin:0 0 20px;">
|
||||
A new version of the transcription engine is available ({availableUpdate}).
|
||||
</p>
|
||||
<div style="display:flex;gap:10px;justify-content:center;">
|
||||
<button
|
||||
style="padding:8px 20px;border:1px solid #555;border-radius:6px;background:transparent;color:#e0e0e0;cursor:pointer;"
|
||||
onclick={() => launchSidecar()}
|
||||
>Skip</button>
|
||||
<button
|
||||
style="padding:8px 20px;border:none;border-radius:6px;background:#4CAF50;color:white;cursor:pointer;font-weight:500;"
|
||||
onclick={() => { sidecarState = "needs_setup"; }}
|
||||
>Update Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if !isConnected}
|
||||
<div class="connecting-overlay" style="background:#1e1e1e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100%;width:100%;">
|
||||
<div class="connecting-content" style="text-align:center;">
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
let showTimestamps = $state(true);
|
||||
let fadeSeconds = $state(10);
|
||||
let maxLines = $state(100);
|
||||
let fontSource = $state("System Font");
|
||||
let fontFamily = $state("Courier");
|
||||
let websafeFont = $state("Arial");
|
||||
let googleFont = $state("Roboto");
|
||||
let fontSize = $state(12);
|
||||
let userColor = $state("#4CAF50");
|
||||
let textColor = $state("#FFFFFF");
|
||||
@@ -99,6 +103,10 @@
|
||||
showTimestamps = cfg.display.show_timestamps;
|
||||
fadeSeconds = cfg.display.fade_after_seconds;
|
||||
maxLines = cfg.display.max_lines;
|
||||
fontSource = cfg.display.font_source ?? "System Font";
|
||||
fontFamily = cfg.display.font_family ?? "Courier";
|
||||
websafeFont = cfg.display.websafe_font ?? "Arial";
|
||||
googleFont = cfg.display.google_font ?? "Roboto";
|
||||
fontSize = cfg.display.font_size;
|
||||
userColor = cfg.display.user_color;
|
||||
textColor = cfg.display.text_color;
|
||||
@@ -174,6 +182,10 @@
|
||||
show_timestamps: showTimestamps,
|
||||
fade_after_seconds: fadeSeconds,
|
||||
max_lines: maxLines,
|
||||
font_source: fontSource,
|
||||
font_family: fontFamily,
|
||||
websafe_font: websafeFont,
|
||||
google_font: googleFont,
|
||||
font_size: fontSize,
|
||||
user_color: userColor,
|
||||
text_color: textColor,
|
||||
@@ -220,6 +232,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangeSidecar() {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("reset_sidecar");
|
||||
// Force a page reload which will re-trigger the setup flow
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error("Failed to reset sidecar:", err);
|
||||
saveMessage = `Error: ${err}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManagedLogin() {
|
||||
try {
|
||||
await backendStore.apiPost("/api/login", {
|
||||
@@ -485,6 +509,95 @@
|
||||
bind:value={maxLines}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="font-source">Font Source</label>
|
||||
<select id="font-source" bind:value={fontSource}>
|
||||
<option value="System Font">System Font</option>
|
||||
<option value="Web-Safe">Web-Safe</option>
|
||||
<option value="Google Font">Google Font</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if fontSource === "System Font"}
|
||||
<div class="field">
|
||||
<label for="font-family">System Font Family</label>
|
||||
<input id="font-family" type="text" bind:value={fontFamily} placeholder="Courier" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if fontSource === "Web-Safe"}
|
||||
<div class="field">
|
||||
<label for="websafe-font">Web-Safe Font</label>
|
||||
<select id="websafe-font" bind:value={websafeFont}>
|
||||
<option value="Arial">Arial</option>
|
||||
<option value="Arial Black">Arial Black</option>
|
||||
<option value="Comic Sans MS">Comic Sans MS</option>
|
||||
<option value="Courier New">Courier New</option>
|
||||
<option value="Georgia">Georgia</option>
|
||||
<option value="Impact">Impact</option>
|
||||
<option value="Lucida Console">Lucida Console</option>
|
||||
<option value="Lucida Sans Unicode">Lucida Sans Unicode</option>
|
||||
<option value="Palatino Linotype">Palatino Linotype</option>
|
||||
<option value="Tahoma">Tahoma</option>
|
||||
<option value="Times New Roman">Times New Roman</option>
|
||||
<option value="Trebuchet MS">Trebuchet MS</option>
|
||||
<option value="Verdana">Verdana</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{#if fontSource === "Google Font"}
|
||||
<div class="field">
|
||||
<label for="google-font">Google Font</label>
|
||||
<select id="google-font" bind:value={googleFont}>
|
||||
<optgroup label="Sans Serif">
|
||||
<option value="Roboto">Roboto</option>
|
||||
<option value="Open Sans">Open Sans</option>
|
||||
<option value="Lato">Lato</option>
|
||||
<option value="Montserrat">Montserrat</option>
|
||||
<option value="Poppins">Poppins</option>
|
||||
<option value="Nunito">Nunito</option>
|
||||
<option value="Raleway">Raleway</option>
|
||||
<option value="Ubuntu">Ubuntu</option>
|
||||
<option value="Rubik">Rubik</option>
|
||||
<option value="Work Sans">Work Sans</option>
|
||||
<option value="Inter">Inter</option>
|
||||
<option value="Outfit">Outfit</option>
|
||||
<option value="Quicksand">Quicksand</option>
|
||||
<option value="Comfortaa">Comfortaa</option>
|
||||
<option value="Varela Round">Varela Round</option>
|
||||
</optgroup>
|
||||
<optgroup label="Serif">
|
||||
<option value="Playfair Display">Playfair Display</option>
|
||||
<option value="Merriweather">Merriweather</option>
|
||||
<option value="Lora">Lora</option>
|
||||
<option value="PT Serif">PT Serif</option>
|
||||
<option value="Crimson Text">Crimson Text</option>
|
||||
</optgroup>
|
||||
<optgroup label="Monospace">
|
||||
<option value="Roboto Mono">Roboto Mono</option>
|
||||
<option value="Source Code Pro">Source Code Pro</option>
|
||||
<option value="Fira Code">Fira Code</option>
|
||||
<option value="JetBrains Mono">JetBrains Mono</option>
|
||||
<option value="IBM Plex Mono">IBM Plex Mono</option>
|
||||
</optgroup>
|
||||
<optgroup label="Display">
|
||||
<option value="Bebas Neue">Bebas Neue</option>
|
||||
<option value="Oswald">Oswald</option>
|
||||
<option value="Righteous">Righteous</option>
|
||||
<option value="Bangers">Bangers</option>
|
||||
<option value="Permanent Marker">Permanent Marker</option>
|
||||
</optgroup>
|
||||
<optgroup label="Handwriting">
|
||||
<option value="Pacifico">Pacifico</option>
|
||||
<option value="Lobster">Lobster</option>
|
||||
<option value="Dancing Script">Dancing Script</option>
|
||||
<option value="Caveat">Caveat</option>
|
||||
<option value="Satisfy">Satisfy</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<p style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
|
||||
Browse more at <a href="https://fonts.google.com" target="_blank" rel="noopener" style="color: var(--accent-blue);">fonts.google.com</a>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="field">
|
||||
<label for="font-size">Font Size: {fontSize}px</label>
|
||||
<input
|
||||
@@ -648,6 +761,17 @@
|
||||
</div>
|
||||
<button onclick={handleCheckUpdates}>Check Now</button>
|
||||
</section>
|
||||
|
||||
<!-- Transcription Engine -->
|
||||
<section class="settings-section">
|
||||
<h3>Transcription Engine</h3>
|
||||
<p style="font-size: 12px; color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Switch between local (Whisper) and cloud (Deepgram) transcription engines.
|
||||
This will stop the current engine, remove the downloaded files, and restart
|
||||
with the new engine selection.
|
||||
</p>
|
||||
<button class="danger-btn" onclick={handleChangeSidecar}>Change Transcription Engine</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
@@ -818,4 +942,18 @@
|
||||
.save-message.error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent-red, #f44336);
|
||||
color: var(--accent-red, #f44336);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -84,11 +84,29 @@
|
||||
|
||||
{#if setupState === "choose"}
|
||||
<p class="setup-description">
|
||||
The app needs to download its transcription engine before you can start.
|
||||
Choose the version that best fits your hardware.
|
||||
Choose a transcription engine. You can change this later in Settings.
|
||||
</p>
|
||||
|
||||
<div class="variant-options">
|
||||
<label class="variant-option" class:selected={variant === "cloud"}>
|
||||
<input
|
||||
type="radio"
|
||||
name="variant"
|
||||
value="cloud"
|
||||
bind:group={variant}
|
||||
/>
|
||||
<div class="variant-info">
|
||||
<span class="variant-name">Cloud (Deepgram)</span>
|
||||
<span class="variant-desc">~50 MB download</span>
|
||||
<span class="variant-detail">
|
||||
Fast, accurate streaming transcription via Deepgram's servers.
|
||||
Requires internet and a Deepgram API key.
|
||||
Best for most users — low resource usage, works on any hardware.
|
||||
</span>
|
||||
<span class="variant-tag recommended">Recommended</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="variant-option" class:selected={variant === "cpu"}>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -97,8 +115,13 @@
|
||||
bind:group={variant}
|
||||
/>
|
||||
<div class="variant-info">
|
||||
<span class="variant-name">Standard (CPU)</span>
|
||||
<span class="variant-desc">Works on all computers (~500 MB download)</span>
|
||||
<span class="variant-name">Local - CPU</span>
|
||||
<span class="variant-desc">~500 MB download</span>
|
||||
<span class="variant-detail">
|
||||
Runs Whisper AI models locally on your CPU. No internet needed
|
||||
after download. Good for privacy or offline use, but slower and
|
||||
uses more system resources than cloud.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -110,8 +133,13 @@
|
||||
bind:group={variant}
|
||||
/>
|
||||
<div class="variant-info">
|
||||
<span class="variant-name">GPU Accelerated (CUDA)</span>
|
||||
<span class="variant-desc">Faster transcription with NVIDIA GPU (~2 GB download)</span>
|
||||
<span class="variant-name">Local - GPU (NVIDIA CUDA)</span>
|
||||
<span class="variant-desc">~2 GB download</span>
|
||||
<span class="variant-detail">
|
||||
Runs Whisper AI models locally using your NVIDIA GPU for fast
|
||||
transcription. No internet needed after download. Requires an
|
||||
NVIDIA GPU with CUDA support.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -260,6 +288,30 @@
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.variant-detail {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.variant-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-top: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.variant-tag.recommended {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Version information for Local Transcription."""
|
||||
|
||||
__version__ = "1.4.18"
|
||||
__version_info__ = (1, 4, 18)
|
||||
__version__ = "2.0.6"
|
||||
__version_info__ = (2, 0, 6)
|
||||
|
||||
# Version history:
|
||||
# 1.4.0 - Auto-update feature:
|
||||
|
||||
Reference in New Issue
Block a user