Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d9d90563cc | ||
|
|
5a674ed199 | ||
|
|
9d78fce3f0 | ||
|
|
a8de39de84 | ||
|
|
bc82584dff | ||
|
|
4d0b4ee1c5 | ||
|
|
c73e9de0ac | ||
|
|
288c6ad6a3 | ||
|
|
af8046f9b1 | ||
|
|
6003885519 | ||
|
|
8829846b53 | ||
|
|
cf449d9338 | ||
|
|
5a6910834c | ||
|
|
a6c7eb5d5e | ||
|
|
135d5d534b | ||
|
|
76f34fe17d | ||
|
|
68ad31b6a7 | ||
|
|
fcbe405e23 | ||
|
|
4adfd2adc6 | ||
|
|
f3843d59f1 |
@@ -13,20 +13,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "20"
|
||||||
|
RELEASE_TAG: "${{ inputs.tag }}"
|
||||||
steps:
|
steps:
|
||||||
- name: Determine tag
|
- name: Show tag
|
||||||
id: tag
|
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.event.inputs.tag }}"
|
echo "Building for tag: ${RELEASE_TAG}"
|
||||||
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
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.tag.outputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -55,7 +50,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get install -y jq
|
sudo apt-get install -y jq
|
||||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
TAG="${{ steps.tag.outputs.tag }}"
|
TAG="${RELEASE_TAG}"
|
||||||
echo "Release tag: ${TAG}"
|
echo "Release tag: ${TAG}"
|
||||||
|
|
||||||
echo "Waiting for release ${TAG} to be available..."
|
echo "Waiting for release ${TAG} to be available..."
|
||||||
|
|||||||
@@ -13,20 +13,15 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "20"
|
||||||
|
RELEASE_TAG: "${{ inputs.tag }}"
|
||||||
steps:
|
steps:
|
||||||
- name: Determine tag
|
- name: Show tag
|
||||||
id: tag
|
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.event.inputs.tag }}"
|
echo "Building for tag: ${RELEASE_TAG}"
|
||||||
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
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.tag.outputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -53,7 +48,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
which jq || brew install jq
|
which jq || brew install jq
|
||||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
TAG="${{ steps.tag.outputs.tag }}"
|
TAG="${RELEASE_TAG}"
|
||||||
echo "Release tag: ${TAG}"
|
echo "Release tag: ${TAG}"
|
||||||
|
|
||||||
echo "Waiting for release ${TAG} to be available..."
|
echo "Waiting for release ${TAG} to be available..."
|
||||||
|
|||||||
@@ -7,27 +7,24 @@ on:
|
|||||||
description: 'Release tag to build (e.g. v1.4.5)'
|
description: 'Release tag to build (e.g. v1.4.5)'
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: "20"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows:
|
||||||
name: Build App (Windows)
|
name: Build App (Windows)
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "20"
|
RELEASE_TAG: "${{ inputs.tag }}"
|
||||||
steps:
|
steps:
|
||||||
- name: Determine tag
|
- name: Show tag
|
||||||
id: tag
|
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
$TAG = "${{ github.event.inputs.tag }}"
|
Write-Host "Building for tag: $env:RELEASE_TAG"
|
||||||
if (-not $TAG) {
|
|
||||||
$TAG = (git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | Select-Object -First 1) -replace '.*refs/tags/', ''
|
|
||||||
}
|
|
||||||
Write-Host "Building for tag: ${TAG}"
|
|
||||||
echo "tag=${TAG}" >> $env:GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.tag.outputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -60,19 +57,24 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||||
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
|
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
|
||||||
$TAG = "${{ steps.tag.outputs.tag }}"
|
$TAG = $env:RELEASE_TAG
|
||||||
Write-Host "Release tag: ${TAG}"
|
Write-Host "Release tag: $TAG"
|
||||||
|
|
||||||
Write-Host "Waiting for release ${TAG} to be available..."
|
if (-not $TAG) {
|
||||||
|
Write-Host "ERROR: RELEASE_TAG is empty"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Waiting for release $TAG to be available..."
|
||||||
$RELEASE_ID = $null
|
$RELEASE_ID = $null
|
||||||
|
|
||||||
for ($i = 1; $i -le 30; $i++) {
|
for ($i = 1; $i -le 30; $i++) {
|
||||||
try {
|
try {
|
||||||
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
|
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
|
||||||
$RELEASE_ID = $release.id
|
$RELEASE_ID = $release.id
|
||||||
|
|
||||||
if ($RELEASE_ID) {
|
if ($RELEASE_ID) {
|
||||||
Write-Host "Found release: ${TAG} (ID: ${RELEASE_ID})"
|
Write-Host "Found release: $TAG (ID: $RELEASE_ID)"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -82,7 +84,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (-not $RELEASE_ID) {
|
if (-not $RELEASE_ID) {
|
||||||
Write-Host "ERROR: Failed to find release for tag ${TAG} after 30 attempts."
|
Write-Host "ERROR: Failed to find release for tag $TAG after 30 attempts."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,17 +92,17 @@ jobs:
|
|||||||
$filename = $_.Name
|
$filename = $_.Name
|
||||||
$encodedName = [System.Uri]::EscapeDataString($filename)
|
$encodedName = [System.Uri]::EscapeDataString($filename)
|
||||||
$size = [math]::Round($_.Length / 1MB, 1)
|
$size = [math]::Round($_.Length / 1MB, 1)
|
||||||
Write-Host "Uploading ${filename} (${size} MB)..."
|
Write-Host "Uploading $filename ($size MB)..."
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
|
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
|
||||||
$existing = $assets | Where-Object { $_.name -eq $filename }
|
$existing = $assets | Where-Object { $_.name -eq $filename }
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
|
Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
|
$uploadUrl = "$REPO_API/releases/$RELEASE_ID/assets?name=$encodedName"
|
||||||
$result = curl.exe --fail --silent --show-error `
|
$result = curl.exe --fail --silent --show-error `
|
||||||
-X POST `
|
-X POST `
|
||||||
-H "Authorization: token $env:BUILD_TOKEN" `
|
-H "Authorization: token $env:BUILD_TOKEN" `
|
||||||
@@ -108,8 +110,8 @@ jobs:
|
|||||||
-T "$($_.FullName)" `
|
-T "$($_.FullName)" `
|
||||||
"$uploadUrl" 2>&1
|
"$uploadUrl" 2>&1
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Host "Upload successful: ${filename}"
|
Write-Host "Upload successful: $filename"
|
||||||
} else {
|
} else {
|
||||||
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
|
Write-Host "WARNING: Upload failed for ${filename}: $result"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,20 +13,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: "3.11"
|
PYTHON_VERSION: "3.11"
|
||||||
|
RELEASE_TAG: "${{ inputs.tag }}"
|
||||||
steps:
|
steps:
|
||||||
- name: Determine tag
|
- name: Show tag
|
||||||
id: tag
|
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.event.inputs.tag }}"
|
echo "Building for tag: ${RELEASE_TAG}"
|
||||||
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
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.tag.outputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: |
|
run: |
|
||||||
@@ -72,7 +67,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get install -y jq
|
sudo apt-get install -y jq
|
||||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
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..."
|
echo "Waiting for sidecar release ${TAG} to be available..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
|
|||||||
@@ -13,20 +13,15 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: "3.11"
|
PYTHON_VERSION: "3.11"
|
||||||
|
RELEASE_TAG: "${{ inputs.tag }}"
|
||||||
steps:
|
steps:
|
||||||
- name: Determine tag
|
- name: Show tag
|
||||||
id: tag
|
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ github.event.inputs.tag }}"
|
echo "Building for tag: ${RELEASE_TAG}"
|
||||||
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
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.tag.outputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: |
|
run: |
|
||||||
@@ -63,7 +58,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
which jq || brew install jq
|
which jq || brew install jq
|
||||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
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..."
|
echo "Waiting for sidecar release ${TAG} to be available..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
|
|||||||
@@ -13,21 +13,16 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: "3.11"
|
PYTHON_VERSION: "3.11"
|
||||||
|
RELEASE_TAG: "${{ inputs.tag }}"
|
||||||
steps:
|
steps:
|
||||||
- name: Determine tag
|
- name: Show tag
|
||||||
id: tag
|
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
$TAG = "${{ github.event.inputs.tag }}"
|
Write-Host "Building for tag: $env:RELEASE_TAG"
|
||||||
if (-not $TAG) {
|
|
||||||
$TAG = (git ls-remote --tags --sort=-v:refname origin 'refs/tags/sidecar-v*' | Select-Object -First 1) -replace '.*refs/tags/', ''
|
|
||||||
}
|
|
||||||
Write-Host "Building for tag: ${TAG}"
|
|
||||||
echo "tag=${TAG}" >> $env:GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.tag.outputs.tag }}
|
ref: ${{ inputs.tag }}
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
shell: powershell
|
shell: powershell
|
||||||
@@ -36,7 +31,6 @@ jobs:
|
|||||||
Write-Host "uv already installed: $(uv --version)"
|
Write-Host "uv already installed: $(uv --version)"
|
||||||
} else {
|
} else {
|
||||||
irm https://astral.sh/uv/install.ps1 | iex
|
irm https://astral.sh/uv/install.ps1 | iex
|
||||||
# Add both possible uv install locations to PATH
|
|
||||||
$uvPaths = @(
|
$uvPaths = @(
|
||||||
"$env:USERPROFILE\.local\bin",
|
"$env:USERPROFILE\.local\bin",
|
||||||
"$env:USERPROFILE\.cargo\bin",
|
"$env:USERPROFILE\.cargo\bin",
|
||||||
@@ -77,8 +71,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
Remove-Item -Recurse -Force dist\local-transcription-backend, build -ErrorAction SilentlyContinue
|
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 pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
|
||||||
# Run pyinstaller directly from venv to prevent uv run from
|
|
||||||
# re-resolving torch back to the CUDA version via pyproject.toml sources
|
|
||||||
.venv\Scripts\pyinstaller.exe local-transcription-headless.spec
|
.venv\Scripts\pyinstaller.exe local-transcription-headless.spec
|
||||||
|
|
||||||
- name: Package sidecar (CPU)
|
- name: Package sidecar (CPU)
|
||||||
@@ -93,18 +85,24 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||||
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
|
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
|
||||||
$TAG = "${{ steps.tag.outputs.tag }}"
|
$TAG = $env:RELEASE_TAG
|
||||||
|
Write-Host "Release tag: $TAG"
|
||||||
|
|
||||||
Write-Host "Waiting for sidecar release ${TAG} to be available..."
|
if (-not $TAG) {
|
||||||
|
Write-Host "ERROR: RELEASE_TAG is empty"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Waiting for sidecar release $TAG to be available..."
|
||||||
$RELEASE_ID = $null
|
$RELEASE_ID = $null
|
||||||
|
|
||||||
for ($i = 1; $i -le 30; $i++) {
|
for ($i = 1; $i -le 30; $i++) {
|
||||||
try {
|
try {
|
||||||
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
|
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
|
||||||
$RELEASE_ID = $release.id
|
$RELEASE_ID = $release.id
|
||||||
|
|
||||||
if ($RELEASE_ID) {
|
if ($RELEASE_ID) {
|
||||||
Write-Host "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
|
Write-Host "Found sidecar release: $TAG (ID: $RELEASE_ID)"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -114,7 +112,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (-not $RELEASE_ID) {
|
if (-not $RELEASE_ID) {
|
||||||
Write-Host "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
|
Write-Host "ERROR: Failed to find sidecar release for tag $TAG after 30 attempts."
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,17 +120,17 @@ jobs:
|
|||||||
$filename = $_.Name
|
$filename = $_.Name
|
||||||
$encodedName = [System.Uri]::EscapeDataString($filename)
|
$encodedName = [System.Uri]::EscapeDataString($filename)
|
||||||
$size = [math]::Round($_.Length / 1MB, 1)
|
$size = [math]::Round($_.Length / 1MB, 1)
|
||||||
Write-Host "Uploading ${filename} (${size} MB)..."
|
Write-Host "Uploading $filename ($size MB)..."
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
|
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
|
||||||
$existing = $assets | Where-Object { $_.name -eq $filename }
|
$existing = $assets | Where-Object { $_.name -eq $filename }
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
|
Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
|
$uploadUrl = "$REPO_API/releases/$RELEASE_ID/assets?name=$encodedName"
|
||||||
$result = curl.exe --fail --silent --show-error `
|
$result = curl.exe --fail --silent --show-error `
|
||||||
-X POST `
|
-X POST `
|
||||||
-H "Authorization: token $env:BUILD_TOKEN" `
|
-H "Authorization: token $env:BUILD_TOKEN" `
|
||||||
@@ -140,8 +138,8 @@ jobs:
|
|||||||
-T "$($_.FullName)" `
|
-T "$($_.FullName)" `
|
||||||
"$uploadUrl" 2>&1
|
"$uploadUrl" 2>&1
|
||||||
if ($LASTEXITCODE -eq 0) {
|
if ($LASTEXITCODE -eq 0) {
|
||||||
Write-Host "Upload successful: ${filename}"
|
Write-Host "Upload successful: $filename"
|
||||||
} else {
|
} else {
|
||||||
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
|
Write-Host "WARNING: Upload failed for ${filename}: $result"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,40 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
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:
|
bump-version:
|
||||||
name: Bump version and tag
|
name: Bump version and tag
|
||||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
new_version: ${{ steps.bump.outputs.new_version }}
|
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
|
for workflow in build-app-linux.yml build-app-windows.yml build-app-macos.yml; do
|
||||||
echo "Dispatching ${workflow} for ${TAG}..."
|
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 "Authorization: token ${BUILD_TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"ref\": \"main\", \"inputs\": {\"tag\": \"${TAG}\"}}" \
|
-d "{\"ref\": \"main\", \"inputs\": {\"tag\": \"${TAG}\"}}" \
|
||||||
"${REPO_API}/actions/workflows/${workflow}/dispatches")
|
"${REPO_API}/actions/workflows/${workflow}/dispatches")
|
||||||
echo " -> HTTP ${HTTP_CODE}"
|
echo " -> HTTP ${HTTP_CODE}"
|
||||||
|
[ "$HTTP_CODE" != "204" ] && cat /tmp/dispatch_resp.txt && echo ""
|
||||||
done
|
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
|
name: Sidecar Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- 'client/**'
|
|
||||||
- 'server/**'
|
|
||||||
- 'backend/**'
|
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'local-transcription-headless.spec'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
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:
|
bump-sidecar-version:
|
||||||
name: Bump sidecar version and tag
|
name: Bump sidecar version and tag
|
||||||
|
needs: test
|
||||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
@@ -62,8 +73,6 @@ jobs:
|
|||||||
echo "New sidecar version: ${NEW_VERSION}"
|
echo "New sidecar version: ${NEW_VERSION}"
|
||||||
|
|
||||||
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
|
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 "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||||
echo "tag=sidecar-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
echo "tag=sidecar-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||||
@@ -75,7 +84,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
NEW_VERSION="${{ steps.bump.outputs.version }}"
|
NEW_VERSION="${{ steps.bump.outputs.version }}"
|
||||||
TAG="${{ steps.bump.outputs.tag }}"
|
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 commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]"
|
||||||
git tag "${TAG}"
|
git tag "${TAG}"
|
||||||
|
|
||||||
@@ -109,7 +118,7 @@ jobs:
|
|||||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
TAG="${{ steps.bump.outputs.tag }}"
|
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}..."
|
echo "Dispatching ${workflow} for ${TAG}..."
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||||
@@ -118,3 +127,6 @@ jobs:
|
|||||||
"${REPO_API}/actions/workflows/${workflow}/dispatches")
|
"${REPO_API}/actions/workflows/${workflow}/dispatches")
|
||||||
echo " -> HTTP ${HTTP_CODE}"
|
echo " -> HTTP ${HTTP_CODE}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# NOTE: Automatic cleanup disabled -- it races with async builds.
|
||||||
|
# Clean up old releases manually from the Gitea UI when needed.
|
||||||
|
|||||||
66
.gitea/workflows/test.yml
Normal file
66
.gitea/workflows/test.yml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-tests:
|
||||||
|
name: Python Backend Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- 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: Run pytest
|
||||||
|
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
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run Vitest
|
||||||
|
run: npx vitest run
|
||||||
|
|
||||||
|
rust-tests:
|
||||||
|
name: Rust Sidecar Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
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 Tauri system dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
|
- name: Run cargo test
|
||||||
|
working-directory: src-tauri
|
||||||
|
run: cargo test
|
||||||
@@ -99,11 +99,19 @@ class APIServer:
|
|||||||
|
|
||||||
self.controller.on_credits_low = on_credits_low
|
self.controller.on_credits_low = on_credits_low
|
||||||
|
|
||||||
|
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
|
||||||
|
"""Set the event loop used for broadcasting (call from uvicorn startup)."""
|
||||||
|
self._event_loop = loop
|
||||||
|
|
||||||
def _broadcast_control(self, data: dict):
|
def _broadcast_control(self, data: dict):
|
||||||
"""Send a message to all connected /ws/control clients."""
|
"""Send a message to all connected /ws/control clients."""
|
||||||
if not self.control_connections:
|
if not self.control_connections:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
loop = getattr(self, '_event_loop', None)
|
||||||
|
if loop is None:
|
||||||
|
return
|
||||||
|
|
||||||
message = json.dumps(data)
|
message = json.dumps(data)
|
||||||
disconnected = []
|
disconnected = []
|
||||||
|
|
||||||
@@ -111,7 +119,7 @@ class APIServer:
|
|||||||
try:
|
try:
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
ws.send_text(message),
|
ws.send_text(message),
|
||||||
asyncio.get_event_loop(),
|
loop,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
disconnected.append(ws)
|
disconnected.append(ws)
|
||||||
@@ -124,6 +132,10 @@ class APIServer:
|
|||||||
app = self.app
|
app = self.app
|
||||||
ctrl = self.controller
|
ctrl = self.controller
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def on_startup():
|
||||||
|
self.set_event_loop(asyncio.get_event_loop())
|
||||||
|
|
||||||
# ── Status ─────────────────────────────────────────────
|
# ── Status ─────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
|
|||||||
@@ -18,13 +18,18 @@ import sys
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from client.config import Config
|
from client.config import Config
|
||||||
from client.device_utils import DeviceManager
|
from client.models import TranscriptionResult
|
||||||
from client.transcription_engine_realtime import RealtimeTranscriptionEngine, TranscriptionResult
|
|
||||||
from client.deepgram_transcription import DeepgramTranscriptionEngine
|
from client.deepgram_transcription import DeepgramTranscriptionEngine
|
||||||
from client.server_sync import ServerSyncClient
|
from client.server_sync import ServerSyncClient
|
||||||
from server.web_display import TranscriptionWebServer
|
from server.web_display import TranscriptionWebServer
|
||||||
from version import __version__
|
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:
|
class AppState:
|
||||||
"""Enum-like class for application states."""
|
"""Enum-like class for application states."""
|
||||||
@@ -89,7 +94,18 @@ class AppController:
|
|||||||
|
|
||||||
def __init__(self, config: Optional[Config] = None):
|
def __init__(self, config: Optional[Config] = None):
|
||||||
self.config = config or Config()
|
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
|
# State
|
||||||
self._state = AppState.INITIALIZING
|
self._state = AppState.INITIALIZING
|
||||||
@@ -243,15 +259,12 @@ class AppController:
|
|||||||
|
|
||||||
def _initialize_engine(self):
|
def _initialize_engine(self):
|
||||||
"""Initialize the transcription engine in a background thread."""
|
"""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_str = self.config.get('audio.input_device', 'default')
|
||||||
audio_device = None if audio_device_str == 'default' else int(audio_device_str)
|
audio_device = None if audio_device_str == 'default' else int(audio_device_str)
|
||||||
|
|
||||||
model = self.config.get('transcription.model', 'base.en')
|
model = self.config.get('transcription.model', 'base.en')
|
||||||
language = self.config.get('transcription.language', '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')
|
compute_type = self.config.get('transcription.compute_type', 'default')
|
||||||
|
|
||||||
self.current_model_size = model
|
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_error_callback(self._on_remote_error)
|
||||||
self.transcription_engine.set_credits_low_callback(self._on_credits_low)
|
self.transcription_engine.set_credits_low_callback(self._on_credits_low)
|
||||||
else:
|
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(
|
self.transcription_engine = RealtimeTranscriptionEngine(
|
||||||
model=model,
|
model=model,
|
||||||
device=device,
|
device=device,
|
||||||
@@ -577,12 +602,18 @@ class AppController:
|
|||||||
if self.config.get('server_sync.enabled', False):
|
if self.config.get('server_sync.enabled', False):
|
||||||
self._start_server_sync()
|
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_model = self.config.get('transcription.model', 'base.en')
|
||||||
new_device = self.config.get('transcription.device', 'auto')
|
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 = (
|
engine_reload_needed = (
|
||||||
self.current_model_size != new_model
|
self.current_model_size != new_model
|
||||||
or self.current_device_config != new_device
|
or self.current_device_config != new_device
|
||||||
|
or current_remote_mode != new_remote_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
if engine_reload_needed:
|
if engine_reload_needed:
|
||||||
@@ -596,7 +627,7 @@ class AppController:
|
|||||||
host = self.config.get('web_server.host', '127.0.0.1')
|
host = self.config.get('web_server.host', '127.0.0.1')
|
||||||
port = self.actual_web_port or self.config.get('web_server.port', 8080)
|
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')
|
remote_mode = self.config.get('remote.mode', 'local')
|
||||||
if remote_mode in ('managed', 'byok') and self.transcription_engine:
|
if remote_mode in ('managed', 'byok') and self.transcription_engine:
|
||||||
@@ -640,10 +671,13 @@ class AppController:
|
|||||||
|
|
||||||
def get_compute_devices(self) -> list[dict]:
|
def get_compute_devices(self) -> list[dict]:
|
||||||
"""List available compute devices."""
|
"""List available compute devices."""
|
||||||
device_info = self.device_manager.get_device_info()
|
|
||||||
devices = [{"id": "auto", "name": "Auto-detect"}]
|
devices = [{"id": "auto", "name": "Auto-detect"}]
|
||||||
for dev_id, dev_name in device_info:
|
if self.device_manager:
|
||||||
devices.append({"id": dev_id, "name": dev_name})
|
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
|
return devices
|
||||||
|
|
||||||
# ── Update Checking ────────────────────────────────────────────
|
# ── Update Checking ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -88,11 +88,16 @@ def main():
|
|||||||
# Create API server wrapping the controller
|
# Create API server wrapping the controller
|
||||||
api_server = APIServer(controller)
|
api_server = APIServer(controller)
|
||||||
|
|
||||||
# Determine actual port (web server may have shifted if port was in use)
|
# OBS display runs on the configured port, API server on port+1
|
||||||
actual_port = controller.actual_web_port or args.port
|
obs_port = controller.actual_web_port or args.port
|
||||||
|
api_port = obs_port + 1
|
||||||
|
|
||||||
# Print ready event so Tauri can discover the port
|
# Print ready event so Tauri can discover the API port
|
||||||
print(json.dumps({"event": "ready", "port": actual_port}), flush=True)
|
print(json.dumps({
|
||||||
|
"event": "ready",
|
||||||
|
"port": api_port,
|
||||||
|
"obs_port": obs_port,
|
||||||
|
}), flush=True)
|
||||||
|
|
||||||
# Run the API server (blocks)
|
# Run the API server (blocks)
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -104,7 +109,7 @@ def main():
|
|||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
api_server.app,
|
api_server.app,
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=actual_port + 1, # API on port+1, OBS display on the main port
|
port=api_port,
|
||||||
log_level="error",
|
log_level="error",
|
||||||
access_log=False,
|
access_log=False,
|
||||||
)
|
)
|
||||||
|
|||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
159
backend/tests/conftest.py
Normal file
159
backend/tests/conftest.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Shared fixtures for backend tests.
|
||||||
|
|
||||||
|
Heavy third-party modules (torch, sounddevice, numpy, RealtimeSTT, etc.) are
|
||||||
|
stubbed at the *sys.modules* level before any backend code is imported. This
|
||||||
|
lets the test suite run on a plain Python install without GPU drivers, audio
|
||||||
|
hardware, or heavyweight ML libraries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ── Project root on sys.path ────────────────────────────────────────
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
if str(project_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stub heavy modules before anything imports them ─────────────────
|
||||||
|
|
||||||
|
def _stub(name: str) -> types.ModuleType:
|
||||||
|
"""Create a stub module and register it in sys.modules if not already present."""
|
||||||
|
if name in sys.modules:
|
||||||
|
return sys.modules[name]
|
||||||
|
mod = types.ModuleType(name)
|
||||||
|
sys.modules[name] = mod
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
# numpy -- must behave like a real module for `import numpy as np`
|
||||||
|
_np = _stub("numpy")
|
||||||
|
_np.float32 = float
|
||||||
|
_np.float64 = float
|
||||||
|
_np.int16 = int
|
||||||
|
_np.ndarray = MagicMock
|
||||||
|
_np.array = MagicMock(return_value=MagicMock())
|
||||||
|
_np.zeros = MagicMock(return_value=MagicMock())
|
||||||
|
_np.frombuffer = MagicMock(return_value=MagicMock())
|
||||||
|
|
||||||
|
# torch + sub-modules
|
||||||
|
_torch = _stub("torch")
|
||||||
|
_torch.cuda = MagicMock()
|
||||||
|
_torch.cuda.is_available = MagicMock(return_value=False)
|
||||||
|
_torch.backends = MagicMock()
|
||||||
|
_torch.backends.mps = MagicMock()
|
||||||
|
_torch.backends.mps.is_available = MagicMock(return_value=False)
|
||||||
|
_stub("torch.cuda")
|
||||||
|
_stub("torch.backends")
|
||||||
|
_stub("torch.backends.mps")
|
||||||
|
_stub("torchaudio")
|
||||||
|
|
||||||
|
# sounddevice
|
||||||
|
_sd = _stub("sounddevice")
|
||||||
|
_sd.query_devices = MagicMock(return_value=[])
|
||||||
|
|
||||||
|
# RealtimeSTT (imported by transcription_engine_realtime)
|
||||||
|
_rtstt = _stub("RealtimeSTT")
|
||||||
|
_rtstt.AudioToTextRecorder = MagicMock
|
||||||
|
|
||||||
|
# faster_whisper (sometimes imported transitively)
|
||||||
|
_stub("faster_whisper")
|
||||||
|
|
||||||
|
# noisereduce
|
||||||
|
_stub("noisereduce")
|
||||||
|
|
||||||
|
# scipy
|
||||||
|
_scipy = _stub("scipy")
|
||||||
|
_stub("scipy.signal")
|
||||||
|
_stub("scipy.io")
|
||||||
|
_stub("scipy.io.wavfile")
|
||||||
|
|
||||||
|
# webrtcvad
|
||||||
|
_stub("webrtcvad")
|
||||||
|
|
||||||
|
# openwakeword
|
||||||
|
_stub("openwakeword")
|
||||||
|
|
||||||
|
# pvporcupine
|
||||||
|
_stub("pvporcupine")
|
||||||
|
|
||||||
|
# PySide6 (should not be needed, but just in case)
|
||||||
|
_stub("PySide6")
|
||||||
|
_stub("PySide6.QtWidgets")
|
||||||
|
_stub("PySide6.QtCore")
|
||||||
|
_stub("PySide6.QtGui")
|
||||||
|
|
||||||
|
# websockets
|
||||||
|
_ws = _stub("websockets")
|
||||||
|
_ws.connect = MagicMock
|
||||||
|
|
||||||
|
# deepgram (cloud transcription)
|
||||||
|
_stub("deepgram")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config(tmp_path):
|
||||||
|
"""Return a Config object backed by a temporary file.
|
||||||
|
|
||||||
|
This avoids touching the real user config at ~/.local-transcription/.
|
||||||
|
"""
|
||||||
|
config_file = tmp_path / "test_config.yaml"
|
||||||
|
from client.config import Config
|
||||||
|
|
||||||
|
config = Config(config_path=str(config_file))
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller(mock_config):
|
||||||
|
"""Return an AppController wired to *mock_config* without starting heavy
|
||||||
|
subsystems (engine, web server, device manager).
|
||||||
|
|
||||||
|
The transcription engine, web server thread, and DeviceManager are all
|
||||||
|
replaced with lightweight mocks so the test suite can run without a GPU,
|
||||||
|
audio hardware, or a free network port.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch("backend.app_controller.DeviceManager") as MockDM, \
|
||||||
|
patch("backend.app_controller.RealtimeTranscriptionEngine"), \
|
||||||
|
patch("backend.app_controller.DeepgramTranscriptionEngine"), \
|
||||||
|
patch("backend.app_controller.TranscriptionWebServer"), \
|
||||||
|
patch("backend.app_controller.ServerSyncClient"):
|
||||||
|
|
||||||
|
# DeviceManager stub
|
||||||
|
dm_instance = MagicMock()
|
||||||
|
dm_instance.get_device_info.return_value = [("cpu", "CPU")]
|
||||||
|
dm_instance.get_device_for_whisper.return_value = "cpu"
|
||||||
|
MockDM.return_value = dm_instance
|
||||||
|
|
||||||
|
from backend.app_controller import AppController
|
||||||
|
|
||||||
|
ctrl = AppController(config=mock_config)
|
||||||
|
yield ctrl
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def api_client(controller):
|
||||||
|
"""Return an httpx.AsyncClient speaking ASGI to the APIServer's FastAPI app.
|
||||||
|
|
||||||
|
Usage in tests::
|
||||||
|
|
||||||
|
async def test_something(api_client):
|
||||||
|
resp = await api_client.get("/api/status")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
"""
|
||||||
|
from backend.api_server import APIServer
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
api = APIServer(controller)
|
||||||
|
|
||||||
|
transport = httpx.ASGITransport(app=api.app)
|
||||||
|
client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
|
||||||
|
return client
|
||||||
150
backend/tests/test_api_server.py
Normal file
150
backend/tests/test_api_server.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Tests for backend.api_server.APIServer REST endpoints."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/status ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_status(api_client):
|
||||||
|
resp = await api_client.get("/api/status")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "state" in data
|
||||||
|
assert "is_transcribing" in data
|
||||||
|
assert "version" in data
|
||||||
|
assert "web_server" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/config ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_config(api_client):
|
||||||
|
resp = await api_client.get("/api/config")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
# The config should be a dict (the raw config mapping)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PUT /api/config ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_config(api_client, controller):
|
||||||
|
"""Updating config via PUT should persist and return success."""
|
||||||
|
# Patch reload_engine to avoid heavy lifting
|
||||||
|
controller.reload_engine = MagicMock(return_value=(True, "ok"))
|
||||||
|
controller.current_model_size = "base.en"
|
||||||
|
controller.current_device_config = "auto"
|
||||||
|
controller.config.set("transcription.model", "base.en")
|
||||||
|
controller.config.set("transcription.device", "auto")
|
||||||
|
|
||||||
|
resp = await api_client.put(
|
||||||
|
"/api/config",
|
||||||
|
json={"settings": {"display.font_size": 24}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
|
||||||
|
# Verify the value was actually saved
|
||||||
|
assert controller.config.get("display.font_size") == 24
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/start (engine not ready) ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_when_not_ready(api_client, controller):
|
||||||
|
"""Starting transcription without an engine should return 400."""
|
||||||
|
controller.transcription_engine = None
|
||||||
|
resp = await api_client.post("/api/start")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/clear ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear(api_client, controller):
|
||||||
|
from client.models import TranscriptionResult
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
controller.transcriptions = [
|
||||||
|
TranscriptionResult(text="One", is_final=True, timestamp=datetime.now(), user_name="U"),
|
||||||
|
]
|
||||||
|
|
||||||
|
resp = await api_client.post("/api/clear")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["cleared"] == 1
|
||||||
|
assert len(controller.transcriptions) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/audio-devices ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_audio_devices(api_client, controller):
|
||||||
|
"""Audio devices endpoint should return a list, even when mocked."""
|
||||||
|
# Mock sounddevice so the test works without audio hardware
|
||||||
|
with patch("backend.app_controller.AppController.get_audio_devices",
|
||||||
|
return_value=[{"index": 0, "name": "Mock Mic"}]):
|
||||||
|
resp = await api_client.get("/api/audio-devices")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "devices" in data
|
||||||
|
assert len(data["devices"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/compute-devices ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_compute_devices(api_client, controller):
|
||||||
|
resp = await api_client.get("/api/compute-devices")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "devices" in data
|
||||||
|
# At minimum we get the "Auto-detect" entry
|
||||||
|
assert any(d["id"] == "auto" for d in data["devices"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/check-update ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_update(api_client, controller):
|
||||||
|
"""check-update should return a dict with an 'available' key."""
|
||||||
|
with patch.object(controller, "check_for_updates",
|
||||||
|
return_value={"available": False, "current_version": "1.0.0"}):
|
||||||
|
resp = await api_client.get("/api/check-update")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "available" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/version ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version(api_client):
|
||||||
|
resp = await api_client.get("/api/version")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "version" in data
|
||||||
|
# Should be a non-empty string
|
||||||
|
assert isinstance(data["version"], str)
|
||||||
|
assert len(data["version"]) > 0
|
||||||
181
backend/tests/test_app_controller.py
Normal file
181
backend/tests/test_app_controller.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"""Tests for backend.app_controller.AppController."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from backend.app_controller import AppState
|
||||||
|
|
||||||
|
|
||||||
|
# ── basic state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state(controller):
|
||||||
|
"""A freshly constructed controller should be INITIALIZING and not transcribing."""
|
||||||
|
assert controller.state == AppState.INITIALIZING
|
||||||
|
assert controller.is_transcribing is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── start / stop ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_transcription_without_engine(controller):
|
||||||
|
"""Starting transcription before the engine is ready should fail gracefully."""
|
||||||
|
controller.transcription_engine = None
|
||||||
|
success, message = controller.start_transcription()
|
||||||
|
assert success is False
|
||||||
|
assert "not ready" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_stop_cycle(controller):
|
||||||
|
"""Full start -> stop cycle with a mocked engine that reports ready."""
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.is_ready.return_value = True
|
||||||
|
engine.start_recording.return_value = True
|
||||||
|
controller.transcription_engine = engine
|
||||||
|
|
||||||
|
# Start
|
||||||
|
ok, msg = controller.start_transcription()
|
||||||
|
assert ok is True
|
||||||
|
assert controller.is_transcribing is True
|
||||||
|
assert controller.state == AppState.TRANSCRIBING
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
ok, msg = controller.stop_transcription()
|
||||||
|
assert ok is True
|
||||||
|
assert controller.is_transcribing is False
|
||||||
|
engine.stop_recording.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_start_rejected(controller):
|
||||||
|
"""Calling start_transcription twice should reject the second call."""
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.is_ready.return_value = True
|
||||||
|
engine.start_recording.return_value = True
|
||||||
|
controller.transcription_engine = engine
|
||||||
|
|
||||||
|
controller.start_transcription()
|
||||||
|
success, message = controller.start_transcription()
|
||||||
|
assert success is False
|
||||||
|
assert "already" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── transcription storage ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_transcriptions(controller):
|
||||||
|
"""clear_transcriptions should empty the list and return the count."""
|
||||||
|
from client.models import TranscriptionResult
|
||||||
|
|
||||||
|
controller.transcriptions = [
|
||||||
|
TranscriptionResult(text="Hello", is_final=True, timestamp=datetime.now(), user_name="Alice"),
|
||||||
|
TranscriptionResult(text="World", is_final=True, timestamp=datetime.now(), user_name="Bob"),
|
||||||
|
]
|
||||||
|
count = controller.clear_transcriptions()
|
||||||
|
assert count == 2
|
||||||
|
assert len(controller.transcriptions) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_transcriptions_text_with_timestamps(controller):
|
||||||
|
"""get_transcriptions_text should include [HH:MM:SS] prefixes when requested."""
|
||||||
|
from client.models import TranscriptionResult
|
||||||
|
|
||||||
|
ts = datetime(2025, 1, 15, 10, 30, 45)
|
||||||
|
controller.transcriptions = [
|
||||||
|
TranscriptionResult(text="Test line", is_final=True, timestamp=ts, user_name="User"),
|
||||||
|
]
|
||||||
|
|
||||||
|
text = controller.get_transcriptions_text(include_timestamps=True)
|
||||||
|
assert "[10:30:45]" in text
|
||||||
|
assert "User:" in text
|
||||||
|
assert "Test line" in text
|
||||||
|
|
||||||
|
|
||||||
|
# ── settings / engine reload ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_settings_triggers_reload_on_model_change(controller):
|
||||||
|
"""Changing the transcription model should trigger an engine reload."""
|
||||||
|
controller.current_model_size = "base.en"
|
||||||
|
controller.current_device_config = "auto"
|
||||||
|
|
||||||
|
# Patch reload_engine so it doesn't actually try to spin up threads
|
||||||
|
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
|
||||||
|
|
||||||
|
reloaded, msg = controller.apply_settings({
|
||||||
|
"transcription.model": "small.en",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert reloaded is True
|
||||||
|
controller.reload_engine.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_settings_no_reload_when_same(controller):
|
||||||
|
"""If model and device haven't changed, no reload should happen."""
|
||||||
|
controller.current_model_size = "base.en"
|
||||||
|
controller.current_device_config = "auto"
|
||||||
|
|
||||||
|
# Ensure config returns the same values
|
||||||
|
controller.config.set("transcription.model", "base.en")
|
||||||
|
controller.config.set("transcription.device", "auto")
|
||||||
|
|
||||||
|
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
|
||||||
|
|
||||||
|
reloaded, msg = controller.apply_settings({
|
||||||
|
"display.font_size": 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert reloaded is False
|
||||||
|
controller.reload_engine.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── transcription callbacks ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_final_transcription_callback_fires(controller):
|
||||||
|
"""_on_final_transcription should append and invoke on_transcription callback."""
|
||||||
|
from client.models import TranscriptionResult
|
||||||
|
|
||||||
|
received = []
|
||||||
|
controller.on_transcription = lambda data: received.append(data)
|
||||||
|
|
||||||
|
controller.is_transcribing = True
|
||||||
|
controller._set_state(AppState.TRANSCRIBING)
|
||||||
|
|
||||||
|
result = TranscriptionResult(
|
||||||
|
text="Hello world",
|
||||||
|
is_final=True,
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
user_name="Tester",
|
||||||
|
)
|
||||||
|
controller._on_final_transcription(result)
|
||||||
|
|
||||||
|
assert len(controller.transcriptions) == 1
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["text"] == "Hello world"
|
||||||
|
assert received[0]["user_name"] == "Tester"
|
||||||
|
assert received[0]["is_preview"] is False
|
||||||
|
|
||||||
|
|
||||||
|
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.models import TranscriptionResult
|
||||||
|
|
||||||
|
controller.is_transcribing = False
|
||||||
|
|
||||||
|
result = TranscriptionResult(
|
||||||
|
text="Should be ignored",
|
||||||
|
is_final=True,
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
user_name="Ghost",
|
||||||
|
)
|
||||||
|
controller._on_final_transcription(result)
|
||||||
|
|
||||||
|
assert len(controller.transcriptions) == 0
|
||||||
56
backend/tests/test_main_headless.py
Normal file
56
backend/tests/test_main_headless.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests for backend.main_headless ready-event JSON format."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_event_reports_api_port_not_obs_port():
|
||||||
|
"""The ready JSON printed by main_headless must set ``port`` to
|
||||||
|
``obs_port + 1`` (the API port), not the OBS display port.
|
||||||
|
|
||||||
|
From main_headless.py::
|
||||||
|
|
||||||
|
obs_port = controller.actual_web_port or args.port
|
||||||
|
api_port = obs_port + 1
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"event": "ready",
|
||||||
|
"port": api_port,
|
||||||
|
"obs_port": obs_port,
|
||||||
|
}), flush=True)
|
||||||
|
|
||||||
|
We verify this contract by reading the source and checking the
|
||||||
|
structure directly (running main() would start a real server).
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
source_path = project_root / "backend" / "main_headless.py"
|
||||||
|
source = source_path.read_text()
|
||||||
|
|
||||||
|
# Verify the key relationships exist in the source:
|
||||||
|
# 1. api_port = obs_port + 1
|
||||||
|
assert "api_port = obs_port + 1" in source, (
|
||||||
|
"Expected `api_port = obs_port + 1` in main_headless.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. The ready event JSON uses api_port for "port", not obs_port
|
||||||
|
assert '"port": api_port' in source or "'port': api_port" in source, (
|
||||||
|
"The ready event should report api_port as 'port'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. obs_port is also included separately
|
||||||
|
assert '"obs_port": obs_port' in source or "'obs_port': obs_port" in source, (
|
||||||
|
"The ready event should also include 'obs_port'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Verify the event name
|
||||||
|
assert '"event": "ready"' in source or "'event': 'ready'" in source, (
|
||||||
|
"The ready event should have event='ready'"
|
||||||
|
)
|
||||||
@@ -17,7 +17,7 @@ from datetime import datetime
|
|||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
|
|
||||||
from client.transcription_engine_realtime import TranscriptionResult
|
from client.models import TranscriptionResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ class DeepgramTranscriptionEngine:
|
|||||||
# Audio parameters
|
# Audio parameters
|
||||||
self.sample_rate: int = 16000
|
self.sample_rate: int = 16000
|
||||||
self.channels: int = 1
|
self.channels: int = 1
|
||||||
self.blocksize: int = 4096
|
self.blocksize: int = 1024 # ~64ms chunks for lower latency streaming
|
||||||
|
|
||||||
# Callbacks
|
# Callbacks
|
||||||
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
|
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
|
||||||
@@ -314,6 +314,8 @@ class DeepgramTranscriptionEngine:
|
|||||||
f"model={self.deepgram_model}"
|
f"model={self.deepgram_model}"
|
||||||
f"&language={self.language}"
|
f"&language={self.language}"
|
||||||
"&interim_results=true"
|
"&interim_results=true"
|
||||||
|
"&punctuate=true"
|
||||||
|
"&smart_format=true"
|
||||||
"&encoding=linear16"
|
"&encoding=linear16"
|
||||||
f"&sample_rate={self.sample_rate}"
|
f"&sample_rate={self.sample_rate}"
|
||||||
f"&channels={self.channels}"
|
f"&channels={self.channels}"
|
||||||
@@ -370,10 +372,16 @@ class DeepgramTranscriptionEngine:
|
|||||||
|
|
||||||
async def _send_loop(self):
|
async def _send_loop(self):
|
||||||
"""Drain the audio queue and push raw PCM bytes over the WebSocket."""
|
"""Drain the audio queue and push raw PCM bytes over the WebSocket."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
pcm_bytes = self._audio_queue.get(timeout=0.1)
|
# Use run_in_executor to avoid blocking the async event loop
|
||||||
except Empty:
|
# (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
|
continue
|
||||||
|
|
||||||
try:
|
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}"
|
||||||
0
client/tests/__init__.py
Normal file
0
client/tests/__init__.py
Normal file
78
client/tests/test_config.py
Normal file
78
client/tests/test_config.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for client.config.Config."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure project root is on path
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
|
||||||
|
from client.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cfg(tmp_path):
|
||||||
|
"""A Config backed by a temp file so we never touch the real user config."""
|
||||||
|
return Config(config_path=str(tmp_path / "test_config.yaml"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── dot-notation get ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_dot_notation_get(cfg):
|
||||||
|
"""Config.get should traverse nested dicts using dot-separated keys."""
|
||||||
|
cfg.config = {"audio": {"sample_rate": 16000}}
|
||||||
|
assert cfg.get("audio.sample_rate") == 16000
|
||||||
|
|
||||||
|
|
||||||
|
# ── dot-notation set ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_dot_notation_set(cfg):
|
||||||
|
"""Config.set should create/update nested values via dot-separated keys."""
|
||||||
|
cfg.set("audio.sample_rate", 44100)
|
||||||
|
assert cfg.config["audio"]["sample_rate"] == 44100
|
||||||
|
|
||||||
|
# Also readable via .get
|
||||||
|
assert cfg.get("audio.sample_rate") == 44100
|
||||||
|
|
||||||
|
|
||||||
|
# ── missing key returns default ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_key_returns_default(cfg):
|
||||||
|
"""Accessing a nonexistent key should return the supplied default."""
|
||||||
|
assert cfg.get("nonexistent.path", "fallback") == "fallback"
|
||||||
|
assert cfg.get("also.missing") is None # default default is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── nested set creates intermediate dicts ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_set_creates_path(cfg):
|
||||||
|
"""Setting a deeply nested key should create all intermediate dicts."""
|
||||||
|
cfg.config = {}
|
||||||
|
cfg.set("a.b.c.d", 42)
|
||||||
|
|
||||||
|
assert cfg.config["a"]["b"]["c"]["d"] == 42
|
||||||
|
assert cfg.get("a.b.c.d") == 42
|
||||||
|
|
||||||
|
|
||||||
|
# ── save and reload round-trip ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_and_reload(tmp_path):
|
||||||
|
"""Values persisted via save() should survive a fresh Config load."""
|
||||||
|
config_file = str(tmp_path / "roundtrip.yaml")
|
||||||
|
|
||||||
|
# Create and populate
|
||||||
|
cfg1 = Config(config_path=config_file)
|
||||||
|
cfg1.set("user.name", "TestUser")
|
||||||
|
cfg1.set("transcription.model", "tiny.en")
|
||||||
|
|
||||||
|
# Load a fresh instance from the same file
|
||||||
|
cfg2 = Config(config_path=config_file)
|
||||||
|
assert cfg2.get("user.name") == "TestUser"
|
||||||
|
assert cfg2.get("transcription.model") == "tiny.en"
|
||||||
@@ -8,30 +8,8 @@ from threading import Lock
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionResult:
|
# Re-export TranscriptionResult from the shared models module for backward compatibility
|
||||||
"""Represents a transcription result."""
|
from client.models import TranscriptionResult # noqa: F401
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert to dictionary."""
|
"""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',
|
||||||
|
)
|
||||||
1085
package-lock.json
generated
1085
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "local-transcription",
|
"name": "local-transcription",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.8",
|
"version": "2.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -12,16 +12,19 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"typescript": "~5.6.0",
|
"typescript": "~5.6.0",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^4.1.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
"@tauri-apps/plugin-process": "^2.0.0"
|
"@tauri-apps/plugin-shell": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "local-transcription"
|
name = "local-transcription"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
|
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -1881,7 +1881,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "local-transcription"
|
name = "local-transcription"
|
||||||
version = "1.4.5"
|
version = "2.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1894,6 +1894,7 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-shell",
|
"tauri-plugin-shell",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "local-transcription"
|
name = "local-transcription"
|
||||||
version = "1.4.8"
|
version = "2.0.6"
|
||||||
description = "Real-time speech-to-text transcription for streamers"
|
description = "Real-time speech-to-text transcription for streamers"
|
||||||
authors = ["Local Transcription Contributors"]
|
authors = ["Local Transcription Contributors"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -25,3 +25,6 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
bytes = "1"
|
bytes = "1"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
14
src-tauri/capabilities/default.json
Normal file
14
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default permissions for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:event:default",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:event:allow-emit",
|
||||||
|
"shell:default",
|
||||||
|
"dialog:default",
|
||||||
|
"process:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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_shell::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.manage(sidecar::ManagedSidecar(Mutex::new(
|
.manage(sidecar::ManagedSidecar(std::sync::Arc::new(Mutex::new(
|
||||||
sidecar::SidecarManager::new(),
|
sidecar::SidecarManager::new(),
|
||||||
)))
|
))))
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let resource_dir = app
|
let resource_dir = app
|
||||||
.path()
|
.path()
|
||||||
@@ -68,6 +68,7 @@ pub fn run() {
|
|||||||
sidecar::get_sidecar_port,
|
sidecar::get_sidecar_port,
|
||||||
sidecar::start_sidecar,
|
sidecar::start_sidecar,
|
||||||
sidecar::stop_sidecar,
|
sidecar::stop_sidecar,
|
||||||
|
sidecar::reset_sidecar,
|
||||||
write_log,
|
write_log,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ fn read_installed_version() -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sidecar_dir_for_version(version: &str) -> PathBuf {
|
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 {
|
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) {
|
fn cleanup_old_versions(current_version: &str) {
|
||||||
let data = data_dir();
|
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) {
|
if let Ok(entries) = std::fs::read_dir(data) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
if name.starts_with("sidecar-v") // e.g. sidecar-v1.0.1
|
if name.starts_with("sidecar-")
|
||||||
&& name != current_dir_name
|
&& name != current_version
|
||||||
&& entry.path().is_dir()
|
&& entry.path().is_dir()
|
||||||
{
|
{
|
||||||
let _ = std::fs::remove_dir_all(entry.path());
|
let _ = std::fs::remove_dir_all(entry.path());
|
||||||
@@ -433,6 +434,20 @@ impl SidecarManager {
|
|||||||
.ok_or_else(|| "Sidecar running but port unknown".into());
|
.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)
|
let is_dev = cfg!(debug_assertions)
|
||||||
|| std::env::var("LOCAL_TRANSCRIPTION_DEV")
|
|| std::env::var("LOCAL_TRANSCRIPTION_DEV")
|
||||||
.map(|v| v == "1")
|
.map(|v| v == "1")
|
||||||
@@ -463,11 +478,63 @@ impl SidecarManager {
|
|||||||
.take()
|
.take()
|
||||||
.ok_or("Failed to capture sidecar stdout")?;
|
.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);
|
let log_dir = DIRS.get().map(|d| d.data_dir.clone());
|
||||||
self.port = Some(port);
|
std::thread::spawn(move || {
|
||||||
Ok(port)
|
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.
|
/// Stop the sidecar process if running.
|
||||||
@@ -487,20 +554,30 @@ impl SidecarManager {
|
|||||||
// -- private helpers -------------------------------------------------------
|
// -- private helpers -------------------------------------------------------
|
||||||
|
|
||||||
fn build_dev_command(&self) -> Result<std::process::Command, String> {
|
fn build_dev_command(&self) -> Result<std::process::Command, String> {
|
||||||
let mut cmd = std::process::Command::new("python");
|
// Use `uv run` to ensure we use the project's venv, not system Python
|
||||||
cmd.args(["-m", "backend.main_headless"]);
|
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)
|
// Find the project root: try CARGO_MANIFEST_DIR first (set at compile time),
|
||||||
if let Some(dirs) = DIRS.get() {
|
// then fall back to resource_dir parent chain
|
||||||
let project_root = dirs
|
let manifest_dir = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from);
|
||||||
.resource_dir
|
let project_root = manifest_dir
|
||||||
.parent() // src-tauri
|
.as_ref()
|
||||||
.and_then(|p| p.parent()); // project root
|
.and_then(|d| d.parent()) // src-tauri -> project root
|
||||||
if let Some(root) = project_root {
|
.or_else(|| {
|
||||||
cmd.current_dir(root);
|
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)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,27 +593,51 @@ impl SidecarManager {
|
|||||||
bin.parent()
|
bin.parent()
|
||||||
.ok_or("Cannot determine sidecar parent dir")?,
|
.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)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wait_for_ready(stdout: std::process::ChildStdout) -> Result<u16, String> {
|
fn wait_for_ready(stdout: std::process::ChildStdout) -> Result<u16, String> {
|
||||||
let reader = std::io::BufReader::new(stdout);
|
use std::sync::mpsc;
|
||||||
let timeout = std::time::Duration::from_secs(120);
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
for line in reader.lines() {
|
let timeout = std::time::Duration::from_secs(120);
|
||||||
if start.elapsed() > timeout {
|
|
||||||
return Err("Timed out waiting for sidecar ready event".into());
|
// Read stdout in a background thread so we can enforce a real timeout.
|
||||||
}
|
// BufReader::lines() blocks indefinitely if no data arrives.
|
||||||
let line = line.map_err(|e| format!("IO error reading stdout: {e}"))?;
|
let (tx, rx) = mpsc::channel();
|
||||||
if let Ok(evt) = serde_json::from_str::<ReadyEvent>(&line) {
|
|
||||||
if evt.event == "ready" {
|
std::thread::spawn(move || {
|
||||||
return Ok(evt.port);
|
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)
|
let _ = tx.send(Err(
|
||||||
}
|
"Sidecar process exited before sending ready event".into(),
|
||||||
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.
|
/// 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]
|
#[tauri::command]
|
||||||
pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Option<u16>, String> {
|
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]
|
#[tauri::command]
|
||||||
pub fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
|
pub async fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
|
||||||
let mut mgr = state
|
let mgr = state.0.clone();
|
||||||
.0
|
// Run blocking sidecar launch in a background thread so it doesn't
|
||||||
.lock()
|
// freeze the Tauri UI while waiting for the ready event (up to 120s).
|
||||||
.map_err(|e| format!("Lock error: {e}"))?;
|
tokio::task::spawn_blocking(move || {
|
||||||
mgr.ensure_running()
|
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]
|
#[tauri::command]
|
||||||
@@ -578,3 +684,399 @@ pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), Strin
|
|||||||
mgr.stop();
|
mgr.stop();
|
||||||
Ok(())
|
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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 1. Platform / arch detection
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_token_returns_valid_value() {
|
||||||
|
let token = platform_token();
|
||||||
|
assert!(
|
||||||
|
["windows", "macos", "linux"].contains(&token),
|
||||||
|
"unexpected platform token: {token}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arch_token_returns_valid_value() {
|
||||||
|
let token = arch_token();
|
||||||
|
assert!(
|
||||||
|
["x86_64", "aarch64"].contains(&token),
|
||||||
|
"unexpected arch token: {token}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 2. Asset name construction
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_prefix_cpu() {
|
||||||
|
let prefix = asset_prefix("cpu");
|
||||||
|
let expected = format!("sidecar-{}-{}-cpu", platform_token(), arch_token());
|
||||||
|
assert_eq!(prefix, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_prefix_cuda() {
|
||||||
|
let prefix = asset_prefix("cuda");
|
||||||
|
let expected = format!("sidecar-{}-{}-cuda", platform_token(), arch_token());
|
||||||
|
assert_eq!(prefix, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn asset_prefix_format_matches_zip_convention() {
|
||||||
|
// The download function looks for assets matching
|
||||||
|
// `{prefix}*.zip`, so verify the prefix starts with "sidecar-"
|
||||||
|
// and contains exactly three hyphens (sidecar-OS-ARCH-VARIANT).
|
||||||
|
let prefix = asset_prefix("cpu");
|
||||||
|
assert!(prefix.starts_with("sidecar-"));
|
||||||
|
assert_eq!(prefix.matches('-').count(), 3, "expected 3 hyphens in '{prefix}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 3. Version parsing — tag_name format "sidecar-vX.Y.Z"
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_tag_starts_with_expected_prefix() {
|
||||||
|
// The code filters releases by `tag_name.starts_with("sidecar-v")`.
|
||||||
|
// Verify the convention: a version string like "sidecar-v1.0.2" passes
|
||||||
|
// the filter, while "v1.0.2" does not.
|
||||||
|
let tag = "sidecar-v1.0.2";
|
||||||
|
assert!(tag.starts_with("sidecar-v"));
|
||||||
|
|
||||||
|
let bad_tag = "v1.0.2";
|
||||||
|
assert!(!bad_tag.starts_with("sidecar-v"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_sidecar_v_prefix() {
|
||||||
|
// The codebase stores the full tag as the version (e.g. "sidecar-v1.0.2").
|
||||||
|
// Verify we can strip the prefix to get just "1.0.2" when needed.
|
||||||
|
let tag = "sidecar-v1.0.2";
|
||||||
|
let semver = tag.strip_prefix("sidecar-v").unwrap();
|
||||||
|
assert_eq!(semver, "1.0.2");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 4. ReadyEvent deserialization
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_event_deserializes_basic() {
|
||||||
|
let json = r#"{"event": "ready", "port": 8081}"#;
|
||||||
|
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(evt.event, "ready");
|
||||||
|
assert_eq!(evt.port, 8081);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_event_deserializes_with_extra_fields() {
|
||||||
|
// The backend may emit additional fields like `obs_port`.
|
||||||
|
// serde should ignore unknown fields by default (deny_unknown_fields
|
||||||
|
// is NOT set on ReadyEvent).
|
||||||
|
let json = r#"{"event": "ready", "port": 8081, "obs_port": 8080}"#;
|
||||||
|
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(evt.event, "ready");
|
||||||
|
assert_eq!(evt.port, 8081);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_event_rejects_missing_port() {
|
||||||
|
let json = r#"{"event": "ready"}"#;
|
||||||
|
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ready_event_rejects_invalid_port_type() {
|
||||||
|
let json = r#"{"event": "ready", "port": "not_a_number"}"#;
|
||||||
|
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helper: initialise DIRS with a temp directory so path-related functions
|
||||||
|
// work. Because OnceLock can only be set once per process, all tests that
|
||||||
|
// need DIRS must coordinate. We use std::sync::Once + a global temp path.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
static TEST_DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Ensure `DIRS` is initialised (idempotent within a test run).
|
||||||
|
/// Returns the data_dir path.
|
||||||
|
fn ensure_dirs_initialised() -> PathBuf {
|
||||||
|
TEST_DATA_DIR
|
||||||
|
.get_or_init(|| {
|
||||||
|
let tmp = tempfile::tempdir().expect("create tempdir");
|
||||||
|
let data = tmp.path().to_path_buf();
|
||||||
|
// We intentionally leak `tmp` so the directory lives for the
|
||||||
|
// entire test-run.
|
||||||
|
std::mem::forget(tmp);
|
||||||
|
let resource = data.join("resource"); // dummy
|
||||||
|
std::fs::create_dir_all(&resource).ok();
|
||||||
|
init_dirs(resource, data.clone());
|
||||||
|
data
|
||||||
|
})
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 5. Path construction (requires init_dirs)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_file_path_is_in_data_dir() {
|
||||||
|
let data = ensure_dirs_initialised();
|
||||||
|
let vf = version_file();
|
||||||
|
assert_eq!(vf, data.join("sidecar-version.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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-v1.2.3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_path_for_version_has_correct_filename() {
|
||||||
|
let _data = ensure_dirs_initialised();
|
||||||
|
let bin = binary_path_for_version("sidecar-v1.2.3");
|
||||||
|
assert_eq!(bin.file_name().unwrap(), BINARY_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_installed_version_none_when_missing() {
|
||||||
|
let _data = ensure_dirs_initialised();
|
||||||
|
// The version file should not exist yet (clean temp dir).
|
||||||
|
// If another test wrote it, this still validates the function
|
||||||
|
// doesn't panic.
|
||||||
|
let _ = read_installed_version(); // should not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_then_read_installed_version() {
|
||||||
|
let _data = ensure_dirs_initialised();
|
||||||
|
let vf = version_file();
|
||||||
|
std::fs::write(&vf, "sidecar-v2.0.0\n").unwrap();
|
||||||
|
let v = read_installed_version().expect("should read version");
|
||||||
|
assert_eq!(v, "sidecar-v2.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 6. Cleanup old versions
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Cleanup tests are combined into one function because
|
||||||
|
/// `cleanup_old_versions` operates on the shared `data_dir()` and
|
||||||
|
/// tests run in parallel, so separate tests would race.
|
||||||
|
#[test]
|
||||||
|
fn cleanup_old_versions_behaviour() {
|
||||||
|
let data = ensure_dirs_initialised();
|
||||||
|
|
||||||
|
// -- Part A: removes stale version dirs, keeps current ----------------
|
||||||
|
let dirs_to_create = ["sidecar-v1.0.0", "sidecar-v1.0.1", "sidecar-v1.0.2"];
|
||||||
|
for d in &dirs_to_create {
|
||||||
|
std::fs::create_dir_all(data.join(d)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
"sidecar-v1.0.0 should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!data.join("sidecar-v1.0.1").exists(),
|
||||||
|
"sidecar-v1.0.1 should be removed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
data.join("sidecar-v1.0.2").exists(),
|
||||||
|
"sidecar-v1.0.2 should be kept"
|
||||||
|
);
|
||||||
|
|
||||||
|
// -- Part B: ignores non-sidecar directories --------------------------
|
||||||
|
let other = data.join("some-other-dir");
|
||||||
|
std::fs::create_dir_all(&other).unwrap();
|
||||||
|
|
||||||
|
cleanup_old_versions("v1.0.2"); // run again — should leave other alone
|
||||||
|
|
||||||
|
assert!(other.exists(), "non-sidecar dir should not be removed");
|
||||||
|
|
||||||
|
// Clean up so we don't affect other tests that share data_dir.
|
||||||
|
let _ = std::fs::remove_dir_all(data.join("sidecar-v1.0.2"));
|
||||||
|
let _ = std::fs::remove_dir_all(&other);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 7. Zip extraction
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_zip_creates_files() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let zip_path = tmp.path().join("test.zip");
|
||||||
|
let dest_dir = tmp.path().join("output");
|
||||||
|
std::fs::create_dir_all(&dest_dir).unwrap();
|
||||||
|
|
||||||
|
// Build a simple zip in memory.
|
||||||
|
{
|
||||||
|
let file = std::fs::File::create(&zip_path).unwrap();
|
||||||
|
let mut writer = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
|
||||||
|
writer.start_file("hello.txt", options).unwrap();
|
||||||
|
writer.write_all(b"Hello, world!").unwrap();
|
||||||
|
|
||||||
|
writer.start_file("subdir/nested.txt", options).unwrap();
|
||||||
|
writer.write_all(b"Nested content").unwrap();
|
||||||
|
|
||||||
|
writer.finish().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_zip(&zip_path, &dest_dir).expect("extraction should succeed");
|
||||||
|
|
||||||
|
let hello = dest_dir.join("hello.txt");
|
||||||
|
assert!(hello.exists(), "hello.txt should exist");
|
||||||
|
assert_eq!(std::fs::read_to_string(&hello).unwrap(), "Hello, world!");
|
||||||
|
|
||||||
|
let nested = dest_dir.join("subdir/nested.txt");
|
||||||
|
assert!(nested.exists(), "subdir/nested.txt should exist");
|
||||||
|
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "Nested content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_zip_error_on_invalid_file() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let bad_zip = tmp.path().join("bad.zip");
|
||||||
|
std::fs::write(&bad_zip, b"not a zip file").unwrap();
|
||||||
|
let dest = tmp.path().join("dest");
|
||||||
|
std::fs::create_dir_all(&dest).unwrap();
|
||||||
|
|
||||||
|
let result = extract_zip(&bad_zip, &dest);
|
||||||
|
assert!(result.is_err(), "should fail on invalid zip");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// SidecarManager unit tests (no process spawning)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_manager_new_is_not_running() {
|
||||||
|
let mut mgr = SidecarManager::new();
|
||||||
|
assert!(!mgr.is_running());
|
||||||
|
assert!(mgr.port().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidecar_manager_stop_when_not_running() {
|
||||||
|
let mut mgr = SidecarManager::new();
|
||||||
|
mgr.stop(); // should not panic
|
||||||
|
assert!(!mgr.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GiteaRelease / GiteaAsset deserialization
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_release_deserializes() {
|
||||||
|
let json = r#"{
|
||||||
|
"tag_name": "sidecar-v1.0.0",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"name": "sidecar-linux-x86_64-cuda.zip",
|
||||||
|
"browser_download_url": "https://example.com/file.zip",
|
||||||
|
"size": 12345
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#;
|
||||||
|
let release: GiteaRelease = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(release.tag_name, "sidecar-v1.0.0");
|
||||||
|
assert_eq!(release.assets.len(), 1);
|
||||||
|
assert_eq!(release.assets[0].name, "sidecar-linux-x86_64-cuda.zip");
|
||||||
|
assert_eq!(release.assets[0].size, 12345);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_release_with_extra_fields() {
|
||||||
|
// Gitea responses include many more fields; serde should ignore them.
|
||||||
|
let json = r#"{
|
||||||
|
"id": 42,
|
||||||
|
"tag_name": "sidecar-v2.0.0",
|
||||||
|
"name": "Release 2.0.0",
|
||||||
|
"body": "changelog here",
|
||||||
|
"draft": false,
|
||||||
|
"prerelease": false,
|
||||||
|
"assets": []
|
||||||
|
}"#;
|
||||||
|
let release: GiteaRelease = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(release.tag_name, "sidecar-v2.0.0");
|
||||||
|
assert!(release.assets.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// DownloadProgress serialization round-trip
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_progress_serializes() {
|
||||||
|
let progress = DownloadProgress {
|
||||||
|
downloaded: 1024,
|
||||||
|
total: 4096,
|
||||||
|
phase: "downloading".into(),
|
||||||
|
message: "50%".into(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&progress).unwrap();
|
||||||
|
assert!(json.contains("\"downloaded\":1024"));
|
||||||
|
assert!(json.contains("\"phase\":\"downloading\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"productName": "Local Transcription",
|
"productName": "Local Transcription",
|
||||||
"version": "1.4.8",
|
"version": "2.0.6",
|
||||||
"identifier": "net.anhonesthost.local-transcription",
|
"identifier": "net.anhonesthost.local-transcription",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
import { backendStore } from "$lib/stores/backend";
|
import { backendStore } from "$lib/stores/backend";
|
||||||
import { configStore } from "$lib/stores/config";
|
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 showSettings = $state(false);
|
||||||
let sidecarState = $state<SidecarState>("checking");
|
let sidecarState = $state<SidecarState>("checking");
|
||||||
let debugLog = $state("");
|
let debugLog = $state("");
|
||||||
|
let availableUpdate = $state("");
|
||||||
|
|
||||||
let obsDisplayUrl = $derived(backendStore.obsUrl);
|
let obsDisplayUrl = $derived(backendStore.obsUrl);
|
||||||
let syncDisplayUrl = $derived(backendStore.syncUrl);
|
let syncDisplayUrl = $derived(backendStore.syncUrl);
|
||||||
@@ -53,6 +54,20 @@
|
|||||||
return;
|
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();
|
await launchSidecar();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Not running in Tauri (browser dev mode) - skip sidecar check
|
// Not running in Tauri (browser dev mode) - skip sidecar check
|
||||||
@@ -61,7 +76,7 @@
|
|||||||
sidecarState = "starting";
|
sidecarState = "starting";
|
||||||
backendStore.setPort(8081);
|
backendStore.setPort(8081);
|
||||||
backendStore.connect();
|
backendStore.connect();
|
||||||
configStore.loadConfig();
|
configStore.fetchConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,13 +93,13 @@
|
|||||||
log(`Sidecar ready on port ${port}`);
|
log(`Sidecar ready on port ${port}`);
|
||||||
backendStore.setPort(port);
|
backendStore.setPort(port);
|
||||||
backendStore.connect();
|
backendStore.connect();
|
||||||
configStore.loadConfig();
|
configStore.fetchConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If sidecar launch fails, still try connecting to default port
|
// If sidecar launch fails, still try connecting to default port
|
||||||
log(`Sidecar launch failed: ${err}, trying default port`);
|
log(`Sidecar launch failed: ${err}, trying default port`);
|
||||||
sidecarState = "starting";
|
sidecarState = "starting";
|
||||||
backendStore.connect();
|
backendStore.connect();
|
||||||
configStore.loadConfig();
|
configStore.fetchConfig();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +133,26 @@
|
|||||||
{:else if sidecarState === "needs_setup"}
|
{:else if sidecarState === "needs_setup"}
|
||||||
<SidecarSetup onComplete={onSidecarReady} />
|
<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}
|
{: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-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;">
|
<div class="connecting-content" style="text-align:center;">
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
let showTimestamps = $state(true);
|
let showTimestamps = $state(true);
|
||||||
let fadeSeconds = $state(10);
|
let fadeSeconds = $state(10);
|
||||||
let maxLines = $state(100);
|
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 fontSize = $state(12);
|
||||||
let userColor = $state("#4CAF50");
|
let userColor = $state("#4CAF50");
|
||||||
let textColor = $state("#FFFFFF");
|
let textColor = $state("#FFFFFF");
|
||||||
@@ -37,10 +41,14 @@
|
|||||||
let syncPassphrase = $state("");
|
let syncPassphrase = $state("");
|
||||||
let remoteMode = $state("local");
|
let remoteMode = $state("local");
|
||||||
let remoteServerUrl = $state("");
|
let remoteServerUrl = $state("");
|
||||||
|
let byokApiKey = $state("");
|
||||||
let managedEmail = $state("");
|
let managedEmail = $state("");
|
||||||
let managedPassword = $state("");
|
let managedPassword = $state("");
|
||||||
let autoCheckUpdates = $state(true);
|
let autoCheckUpdates = $state(true);
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let saveMessage = $state("");
|
||||||
|
|
||||||
// Fetched device lists
|
// Fetched device lists
|
||||||
let audioDevices = $state<{ id: string; name: string }[]>([]);
|
let audioDevices = $state<{ id: string; name: string }[]>([]);
|
||||||
let computeDevices = $state<{ id: string; name: string }[]>([]);
|
let computeDevices = $state<{ id: string; name: string }[]>([]);
|
||||||
@@ -95,6 +103,10 @@
|
|||||||
showTimestamps = cfg.display.show_timestamps;
|
showTimestamps = cfg.display.show_timestamps;
|
||||||
fadeSeconds = cfg.display.fade_after_seconds;
|
fadeSeconds = cfg.display.fade_after_seconds;
|
||||||
maxLines = cfg.display.max_lines;
|
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;
|
fontSize = cfg.display.font_size;
|
||||||
userColor = cfg.display.user_color;
|
userColor = cfg.display.user_color;
|
||||||
textColor = cfg.display.text_color;
|
textColor = cfg.display.text_color;
|
||||||
@@ -107,6 +119,7 @@
|
|||||||
syncPassphrase = cfg.server_sync.passphrase;
|
syncPassphrase = cfg.server_sync.passphrase;
|
||||||
remoteMode = cfg.remote.mode;
|
remoteMode = cfg.remote.mode;
|
||||||
remoteServerUrl = cfg.remote.server_url;
|
remoteServerUrl = cfg.remote.server_url;
|
||||||
|
byokApiKey = cfg.remote.byok_api_key ?? "";
|
||||||
autoCheckUpdates = cfg.updates.auto_check;
|
autoCheckUpdates = cfg.updates.auto_check;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +182,10 @@
|
|||||||
show_timestamps: showTimestamps,
|
show_timestamps: showTimestamps,
|
||||||
fade_after_seconds: fadeSeconds,
|
fade_after_seconds: fadeSeconds,
|
||||||
max_lines: maxLines,
|
max_lines: maxLines,
|
||||||
|
font_source: fontSource,
|
||||||
|
font_family: fontFamily,
|
||||||
|
websafe_font: websafeFont,
|
||||||
|
google_font: googleFont,
|
||||||
font_size: fontSize,
|
font_size: fontSize,
|
||||||
user_color: userColor,
|
user_color: userColor,
|
||||||
text_color: textColor,
|
text_color: textColor,
|
||||||
@@ -183,17 +200,23 @@
|
|||||||
remote: {
|
remote: {
|
||||||
mode: remoteMode,
|
mode: remoteMode,
|
||||||
server_url: remoteServerUrl,
|
server_url: remoteServerUrl,
|
||||||
|
byok_api_key: byokApiKey,
|
||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
auto_check: autoCheckUpdates,
|
auto_check: autoCheckUpdates,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
saveMessage = "";
|
||||||
try {
|
try {
|
||||||
await configStore.saveConfig(updates);
|
await configStore.updateConfig(updates);
|
||||||
onClose();
|
saveMessage = "Settings saved!";
|
||||||
|
setTimeout(() => onClose(), 600);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save settings:", err);
|
console.error("Failed to save settings:", err);
|
||||||
|
saveMessage = `Error: ${err}`;
|
||||||
|
saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,15 +226,27 @@
|
|||||||
|
|
||||||
async function handleCheckUpdates() {
|
async function handleCheckUpdates() {
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/check-updates");
|
await backendStore.apiGet("/api/check-update");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to check for updates:", err);
|
console.error("Failed to check for updates:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function handleManagedLogin() {
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/remote/login", {
|
await backendStore.apiPost("/api/login", {
|
||||||
email: managedEmail,
|
email: managedEmail,
|
||||||
password: managedPassword,
|
password: managedPassword,
|
||||||
});
|
});
|
||||||
@@ -222,7 +257,7 @@
|
|||||||
|
|
||||||
async function handleManagedRegister() {
|
async function handleManagedRegister() {
|
||||||
try {
|
try {
|
||||||
await backendStore.apiPost("/api/remote/register", {
|
await backendStore.apiPost("/api/register", {
|
||||||
email: managedEmail,
|
email: managedEmail,
|
||||||
password: managedPassword,
|
password: managedPassword,
|
||||||
});
|
});
|
||||||
@@ -474,6 +509,95 @@
|
|||||||
bind:value={maxLines}
|
bind:value={maxLines}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label for="font-size">Font Size: {fontSize}px</label>
|
<label for="font-size">Font Size: {fontSize}px</label>
|
||||||
<input
|
<input
|
||||||
@@ -572,7 +696,7 @@
|
|||||||
BYOK (Bring Your Own Key)
|
BYOK (Bring Your Own Key)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{#if remoteMode !== "local"}
|
{#if remoteMode === "managed"}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="remote-url">Server URL</label>
|
<label for="remote-url">Server URL</label>
|
||||||
<input
|
<input
|
||||||
@@ -583,6 +707,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if remoteMode === "byok"}
|
||||||
|
<div class="field">
|
||||||
|
<label for="byok-key">Deepgram API Key</label>
|
||||||
|
<input
|
||||||
|
id="byok-key"
|
||||||
|
type="password"
|
||||||
|
bind:value={byokApiKey}
|
||||||
|
placeholder="Enter your Deepgram API key"
|
||||||
|
/>
|
||||||
|
<p style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
|
||||||
|
Get a key at <a href="https://console.deepgram.com" target="_blank" rel="noopener" style="color: var(--accent-blue);">console.deepgram.com</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if remoteMode === "managed"}
|
{#if remoteMode === "managed"}
|
||||||
<div class="managed-auth">
|
<div class="managed-auth">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -623,11 +761,27 @@
|
|||||||
</div>
|
</div>
|
||||||
<button onclick={handleCheckUpdates}>Check Now</button>
|
<button onclick={handleCheckUpdates}>Check Now</button>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
<div class="settings-footer">
|
<div class="settings-footer">
|
||||||
<button onclick={handleCancel}>Cancel</button>
|
{#if saveMessage}
|
||||||
<button class="primary" onclick={handleSave}>Save</button>
|
<span class="save-message" class:error={saveMessage.startsWith("Error")}>{saveMessage}</span>
|
||||||
|
{/if}
|
||||||
|
<button onclick={handleCancel} disabled={saving}>Cancel</button>
|
||||||
|
<button class="primary" onclick={handleSave} disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -771,10 +925,35 @@
|
|||||||
|
|
||||||
.settings-footer {
|
.settings-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-message {
|
||||||
|
margin-right: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -84,11 +84,29 @@
|
|||||||
|
|
||||||
{#if setupState === "choose"}
|
{#if setupState === "choose"}
|
||||||
<p class="setup-description">
|
<p class="setup-description">
|
||||||
The app needs to download its transcription engine before you can start.
|
Choose a transcription engine. You can change this later in Settings.
|
||||||
Choose the version that best fits your hardware.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="variant-options">
|
<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"}>
|
<label class="variant-option" class:selected={variant === "cpu"}>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -97,8 +115,13 @@
|
|||||||
bind:group={variant}
|
bind:group={variant}
|
||||||
/>
|
/>
|
||||||
<div class="variant-info">
|
<div class="variant-info">
|
||||||
<span class="variant-name">Standard (CPU)</span>
|
<span class="variant-name">Local - CPU</span>
|
||||||
<span class="variant-desc">Works on all computers (~500 MB download)</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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -110,8 +133,13 @@
|
|||||||
bind:group={variant}
|
bind:group={variant}
|
||||||
/>
|
/>
|
||||||
<div class="variant-info">
|
<div class="variant-info">
|
||||||
<span class="variant-name">GPU Accelerated (CUDA)</span>
|
<span class="variant-name">Local - GPU (NVIDIA CUDA)</span>
|
||||||
<span class="variant-desc">Faster transcription with NVIDIA GPU (~2 GB download)</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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,6 +288,30 @@
|
|||||||
color: #888;
|
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 {
|
.download-btn {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -54,6 +54,35 @@ async function apiFetch(path: string, options?: RequestInit): Promise<Response>
|
|||||||
return fetch(url, { ...options, headers });
|
return fetch(url, { ...options, headers });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Status polling ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let statusPollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(apiUrl("/api/status"));
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.state) {
|
||||||
|
state.appState = data.state as AppState;
|
||||||
|
}
|
||||||
|
if (data.engine_device) {
|
||||||
|
state.deviceInfo = data.engine_device;
|
||||||
|
}
|
||||||
|
if (data.version) {
|
||||||
|
state.version = data.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API not ready yet, will retry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep polling every 2s while still initializing
|
||||||
|
if (state.appState === "initializing" && state.connectionState === "connected") {
|
||||||
|
statusPollTimer = setTimeout(pollStatus, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── WebSocket management ─────────────────────────────────────────────
|
// ── WebSocket management ─────────────────────────────────────────────
|
||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
@@ -80,6 +109,9 @@ function _openSocket() {
|
|||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
// Poll status to catch engine ready state that may have been
|
||||||
|
// missed (engine can finish before WebSocket connects)
|
||||||
|
pollStatus();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -132,6 +164,10 @@ function _scheduleReconnect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
|
if (statusPollTimer) {
|
||||||
|
clearTimeout(statusPollTimer);
|
||||||
|
statusPollTimer = null;
|
||||||
|
}
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
@@ -255,6 +291,14 @@ export const backendStore = {
|
|||||||
get wsUrl() {
|
get wsUrl() {
|
||||||
return `ws://localhost:${state.port}/ws/control`;
|
return `ws://localhost:${state.port}/ws/control`;
|
||||||
},
|
},
|
||||||
|
get obsUrl() {
|
||||||
|
// OBS display runs on the web server port (one below the API port)
|
||||||
|
const obsPort = state.port > 0 ? state.port - 1 : 8080;
|
||||||
|
return `http://localhost:${obsPort}`;
|
||||||
|
},
|
||||||
|
get syncUrl() {
|
||||||
|
return "";
|
||||||
|
},
|
||||||
setPort,
|
setPort,
|
||||||
connect: connectWebSocket,
|
connect: connectWebSocket,
|
||||||
disconnect,
|
disconnect,
|
||||||
77
src/lib/stores/backend.test.ts
Normal file
77
src/lib/stores/backend.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { backendStore } from "./backend.svelte.ts";
|
||||||
|
|
||||||
|
// Mock WebSocket globally so the store module can reference it
|
||||||
|
class MockWebSocket {
|
||||||
|
onopen: ((ev: Event) => void) | null = null;
|
||||||
|
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||||
|
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||||
|
onerror: ((ev: Event) => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
// Mock fetch to prevent real network calls
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("backend store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
backendStore.disconnect();
|
||||||
|
backendStore.setPort(8081);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_exports_expected_properties", () => {
|
||||||
|
expect(backendStore).toHaveProperty("port");
|
||||||
|
expect(backendStore).toHaveProperty("connectionState");
|
||||||
|
expect(backendStore).toHaveProperty("connected");
|
||||||
|
expect(backendStore).toHaveProperty("appState");
|
||||||
|
expect(backendStore).toHaveProperty("stateMessage");
|
||||||
|
expect(backendStore).toHaveProperty("deviceInfo");
|
||||||
|
expect(backendStore).toHaveProperty("version");
|
||||||
|
expect(backendStore).toHaveProperty("lastError");
|
||||||
|
expect(backendStore).toHaveProperty("apiBaseUrl");
|
||||||
|
expect(backendStore).toHaveProperty("wsUrl");
|
||||||
|
expect(backendStore).toHaveProperty("obsUrl");
|
||||||
|
expect(backendStore).toHaveProperty("syncUrl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_exports_expected_methods", () => {
|
||||||
|
expect(typeof backendStore.setPort).toBe("function");
|
||||||
|
expect(typeof backendStore.connect).toBe("function");
|
||||||
|
expect(typeof backendStore.disconnect).toBe("function");
|
||||||
|
expect(typeof backendStore.apiUrl).toBe("function");
|
||||||
|
expect(typeof backendStore.apiFetch).toBe("function");
|
||||||
|
expect(typeof backendStore.apiGet).toBe("function");
|
||||||
|
expect(typeof backendStore.apiPost).toBe("function");
|
||||||
|
expect(typeof backendStore.apiPut).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_obsUrl_derives_from_port", () => {
|
||||||
|
backendStore.setPort(8081);
|
||||||
|
expect(backendStore.obsUrl).toBe("http://localhost:8080");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_apiBaseUrl_uses_port", () => {
|
||||||
|
backendStore.setPort(8081);
|
||||||
|
expect(backendStore.apiBaseUrl).toBe("http://localhost:8081");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_wsUrl_uses_port", () => {
|
||||||
|
backendStore.setPort(8081);
|
||||||
|
expect(backendStore.wsUrl).toBe("ws://localhost:8081/ws/control");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_initial_state", () => {
|
||||||
|
// After disconnect() in beforeEach, state should be disconnected
|
||||||
|
expect(backendStore.connectionState).toBe("disconnected");
|
||||||
|
expect(backendStore.appState).toBe("initializing");
|
||||||
|
});
|
||||||
|
});
|
||||||
48
src/lib/stores/config.test.ts
Normal file
48
src/lib/stores/config.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock fetch so the backend store module doesn't make real requests
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock WebSocket for the backend store dependency
|
||||||
|
class MockWebSocket {
|
||||||
|
onopen: ((ev: Event) => void) | null = null;
|
||||||
|
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||||
|
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||||
|
onerror: ((ev: Event) => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
import { configStore } from "./config.svelte.ts";
|
||||||
|
|
||||||
|
describe("config store", () => {
|
||||||
|
it("test_has_fetchConfig_method", () => {
|
||||||
|
expect(typeof configStore.fetchConfig).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_has_updateConfig_method", () => {
|
||||||
|
expect(typeof configStore.updateConfig).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_config_defaults_have_expected_keys", () => {
|
||||||
|
const cfg = configStore.config;
|
||||||
|
expect(cfg).toHaveProperty("user");
|
||||||
|
expect(cfg).toHaveProperty("audio");
|
||||||
|
expect(cfg).toHaveProperty("transcription");
|
||||||
|
expect(cfg).toHaveProperty("display");
|
||||||
|
expect(cfg).toHaveProperty("remote");
|
||||||
|
expect(cfg).toHaveProperty("updates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_remote_config_has_byok_api_key", () => {
|
||||||
|
expect(configStore.config.remote.byok_api_key).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
src/lib/stores/file-extension.test.ts
Normal file
34
src/lib/stores/file-extension.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
describe("store file extensions", () => {
|
||||||
|
it("test_store_files_use_svelte_ts_extension", () => {
|
||||||
|
const storesDir = path.resolve(__dirname);
|
||||||
|
const files = fs.readdirSync(storesDir);
|
||||||
|
|
||||||
|
// Find .ts files that are NOT .svelte.ts and NOT test files
|
||||||
|
const plainTsFiles = files.filter(
|
||||||
|
(f) =>
|
||||||
|
f.endsWith(".ts") &&
|
||||||
|
!f.endsWith(".svelte.ts") &&
|
||||||
|
!f.endsWith(".test.ts")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of plainTsFiles) {
|
||||||
|
const content = fs.readFileSync(path.join(storesDir, file), "utf-8");
|
||||||
|
expect(content).not.toMatch(
|
||||||
|
/\$state\s*[<(]/,
|
||||||
|
`${file} uses $state() but does not have .svelte.ts extension`
|
||||||
|
);
|
||||||
|
expect(content).not.toMatch(
|
||||||
|
/\$derived\s*[<(]/,
|
||||||
|
`${file} uses $derived() but does not have .svelte.ts extension`
|
||||||
|
);
|
||||||
|
expect(content).not.toMatch(
|
||||||
|
/\$effect\s*[<(]/,
|
||||||
|
`${file} uses $effect() but does not have .svelte.ts extension`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/lib/stores/transcriptions.test.ts
Normal file
71
src/lib/stores/transcriptions.test.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Mock WebSocket for the backend store dependency (loaded transitively)
|
||||||
|
class MockWebSocket {
|
||||||
|
onopen: ((ev: Event) => void) | null = null;
|
||||||
|
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||||
|
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||||
|
onerror: ((ev: Event) => void) | null = null;
|
||||||
|
close = vi.fn();
|
||||||
|
}
|
||||||
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
import { transcriptionStore } from "./transcriptions.svelte.ts";
|
||||||
|
|
||||||
|
describe("transcriptions store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
transcriptionStore.clearAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_addTranscription", () => {
|
||||||
|
transcriptionStore.addTranscription({
|
||||||
|
text: "Hello world",
|
||||||
|
user_name: "TestUser",
|
||||||
|
timestamp: "12:00:00",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(transcriptionStore.items.length).toBe(1);
|
||||||
|
expect(transcriptionStore.items[0].text).toBe("Hello world");
|
||||||
|
expect(transcriptionStore.items[0].userName).toBe("TestUser");
|
||||||
|
expect(transcriptionStore.items[0].timestamp).toBe("12:00:00");
|
||||||
|
expect(transcriptionStore.items[0].isPreview).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_clearAll", () => {
|
||||||
|
transcriptionStore.addTranscription({ text: "One" });
|
||||||
|
transcriptionStore.addTranscription({ text: "Two" });
|
||||||
|
expect(transcriptionStore.items.length).toBe(2);
|
||||||
|
|
||||||
|
transcriptionStore.clearAll();
|
||||||
|
expect(transcriptionStore.items.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_getPlainText", () => {
|
||||||
|
transcriptionStore.addTranscription({
|
||||||
|
text: "Hello",
|
||||||
|
user_name: "Alice",
|
||||||
|
timestamp: "10:00",
|
||||||
|
});
|
||||||
|
transcriptionStore.addTranscription({
|
||||||
|
text: "World",
|
||||||
|
user_name: "Bob",
|
||||||
|
timestamp: "10:01",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = transcriptionStore.getPlainText();
|
||||||
|
expect(text).toContain("[10:00] Alice: Hello");
|
||||||
|
expect(text).toContain("[10:01] Bob: World");
|
||||||
|
// Lines separated by newline
|
||||||
|
expect(text.split("\n").length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Version information for Local Transcription."""
|
"""Version information for Local Transcription."""
|
||||||
|
|
||||||
__version__ = "1.4.8"
|
__version__ = "2.0.6"
|
||||||
__version_info__ = (1, 4, 8)
|
__version_info__ = (2, 0, 6)
|
||||||
|
|
||||||
# Version history:
|
# Version history:
|
||||||
# 1.4.0 - Auto-update feature:
|
# 1.4.0 - Auto-update feature:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
alias: {
|
alias: {
|
||||||
$lib: path.resolve("./src/lib"),
|
$lib: path.resolve("./src/lib"),
|
||||||
},
|
},
|
||||||
|
extensions: [".svelte.ts", ".ts", ".svelte", ".js", ".mjs", ".mts"],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
@@ -18,4 +19,8 @@ export default defineConfig({
|
|||||||
ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"],
|
ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user