Archived
Compare commits
113
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d220158dd7 | ||
|
|
8670e19acc | ||
|
|
812cc4ac5e | ||
|
|
4aa19eee86 | ||
|
|
b8dfe0f1ba | ||
|
|
5837b97a20 | ||
|
|
ab09a3e9da | ||
|
|
5343a28a08 | ||
|
|
f0bf026133 | ||
|
|
37a029d1c6 | ||
|
|
5ec030387f | ||
|
|
4d9bdba903 | ||
|
|
a7a3bcd102 | ||
|
|
115d93482a | ||
|
|
fb672cbaef | ||
|
|
d8c79be094 | ||
|
|
2811f5bb9c | ||
|
|
30127d68e7 | ||
|
|
ae61c8c75a | ||
|
|
2654200fe9 | ||
|
|
cae0c0b265 | ||
|
|
91b27ac22e | ||
|
|
1210acd07f | ||
|
|
352615c15c | ||
|
|
a3bcc5bee5 | ||
|
|
b91fe876f9 | ||
|
|
7e04d6b4af | ||
|
|
15c4e262b9 | ||
|
|
2246723220 | ||
|
|
1c586738f3 | ||
|
|
fb02a24334 | ||
|
|
ce64cacc5e | ||
|
|
14a7ca3b30 | ||
|
|
5b7387f9c6 | ||
|
|
293362baa1 | ||
|
|
41f50dedec | ||
|
|
d8b7811153 | ||
|
|
ec8922672c | ||
|
|
375669f657 | ||
|
|
c8b11fb0ad | ||
|
|
273a926f03 | ||
|
|
5bbbc38875 | ||
|
|
d50be6654d | ||
|
|
68abf49018 | ||
|
|
8cc2a3ec7a | ||
|
|
8aa9dfc644 | ||
|
|
3f16aa838d | ||
|
|
3d3d7ec3c5 | ||
|
|
bb039399fc | ||
|
|
9dcb14e92c | ||
|
|
8db9b8298b | ||
|
|
411779f578 | ||
|
|
bc6055a707 | ||
|
|
e42a922507 | ||
|
|
8fc2d11c5f | ||
|
|
11832e911b | ||
|
|
18e6b974c0 | ||
|
|
08e464daaf | ||
|
|
5d22adcaa4 | ||
|
|
36b4f7dad5 | ||
|
|
1ecb23b83f | ||
|
|
4b88871a9b | ||
|
|
0ae48a67d5 | ||
|
|
924cae6c75 | ||
|
|
5139936e18 | ||
|
|
47724f1ac0 | ||
|
|
3b204be37e | ||
|
|
4c02a48135 | ||
|
|
997e97c19a | ||
|
|
6ca8fc41b2 | ||
|
|
d9d90563cc | ||
|
|
5a674ed199 | ||
|
|
9d78fce3f0 | ||
|
|
a8de39de84 | ||
|
|
bc82584dff | ||
|
|
4d0b4ee1c5 | ||
|
|
c73e9de0ac | ||
|
|
288c6ad6a3 | ||
|
|
af8046f9b1 | ||
|
|
6003885519 | ||
|
|
8829846b53 | ||
|
|
cf449d9338 | ||
|
|
5a6910834c | ||
|
|
a6c7eb5d5e | ||
|
|
135d5d534b | ||
|
|
76f34fe17d | ||
|
|
68ad31b6a7 | ||
|
|
fcbe405e23 | ||
|
|
4adfd2adc6 | ||
|
|
f3843d59f1 | ||
|
|
ad68251e04 | ||
|
|
9468d01a88 | ||
|
|
a3151ad55e | ||
|
|
5bff40e9b4 | ||
|
|
0ccb02ba27 | ||
|
|
aa4033b412 | ||
|
|
b4b9435317 | ||
|
|
ee1d4f8643 | ||
|
|
4a186d1de6 | ||
|
|
fff37992b1 | ||
|
|
8afe3230d3 | ||
|
|
04e7fb1a99 | ||
|
|
9a282215c9 | ||
|
|
cc2d17a627 | ||
|
|
61c5ffa4fa | ||
|
|
289b9dabe1 | ||
|
|
9522f28c57 | ||
|
|
a8e2e7dca8 | ||
|
|
3bcf4f09a3 | ||
|
|
ef5734ef15 | ||
|
|
c9db43d56c | ||
|
|
4c519a109a | ||
|
|
47ca74e75d |
@@ -0,0 +1,95 @@
|
||||
name: Build App (Linux)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build (e.g. v1.4.5)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Build App (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
run: |
|
||||
echo "Building for tag: ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Rust stable
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils rpm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Upload to release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
sudo apt-get install -y jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
echo "Release tag: ${TAG}"
|
||||
|
||||
echo "Waiting for release ${TAG} to be available..."
|
||||
RELEASE_ID=""
|
||||
for i in $(seq 1 30); do
|
||||
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}")
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
|
||||
|
||||
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
|
||||
echo "Found release: ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Failed to find release for tag ${TAG} after 30 attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
find src-tauri/target/release/bundle -type f \( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) | while IFS= read -r file; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Build App (macOS)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build (e.g. v1.4.5)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
name: Build App (macOS)
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
run: |
|
||||
echo "Building for tag: ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Rust stable
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install system dependencies
|
||||
run: brew install --quiet create-dmg || true
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Upload to release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
which jq || brew install jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
echo "Release tag: ${TAG}"
|
||||
|
||||
echo "Waiting for release ${TAG} to be available..."
|
||||
RELEASE_ID=""
|
||||
for i in $(seq 1 30); do
|
||||
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}")
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
|
||||
|
||||
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
|
||||
echo "Found release: ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Failed to find release for tag ${TAG} after 30 attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
find src-tauri/target/release/bundle -type f -name "*.dmg" | while IFS= read -r file; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Build App (Windows)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to build (e.g. v1.4.5)'
|
||||
required: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build App (Windows)
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
shell: powershell
|
||||
run: |
|
||||
Write-Host "Building for tag: $env:RELEASE_TAG"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Rust stable
|
||||
shell: powershell
|
||||
run: |
|
||||
if (Get-Command rustup -ErrorAction SilentlyContinue) {
|
||||
rustup default stable
|
||||
} else {
|
||||
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable
|
||||
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
}
|
||||
|
||||
- name: Install npm dependencies
|
||||
shell: powershell
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
shell: powershell
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Upload to release
|
||||
shell: powershell
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
|
||||
$TAG = $env:RELEASE_TAG
|
||||
Write-Host "Release tag: $TAG"
|
||||
|
||||
if (-not $TAG) {
|
||||
Write-Host "ERROR: RELEASE_TAG is empty"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Waiting for release $TAG to be available..."
|
||||
$RELEASE_ID = $null
|
||||
|
||||
for ($i = 1; $i -le 30; $i++) {
|
||||
try {
|
||||
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
|
||||
$RELEASE_ID = $release.id
|
||||
|
||||
if ($RELEASE_ID) {
|
||||
Write-Host "Found release: $TAG (ID: $RELEASE_ID)"
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Write-Host "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
|
||||
if (-not $RELEASE_ID) {
|
||||
Write-Host "ERROR: Failed to find release for tag $TAG after 30 attempts."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Get-ChildItem -Path src-tauri\target\release\bundle -Recurse -Include *.msi,*-setup.exe | ForEach-Object {
|
||||
$filename = $_.Name
|
||||
$encodedName = [System.Uri]::EscapeDataString($filename)
|
||||
$size = [math]::Round($_.Length / 1MB, 1)
|
||||
Write-Host "Uploading $filename ($size MB)..."
|
||||
|
||||
try {
|
||||
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
|
||||
$existing = $assets | Where-Object { $_.name -eq $filename }
|
||||
if ($existing) {
|
||||
Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$uploadUrl = "$REPO_API/releases/$RELEASE_ID/assets?name=$encodedName"
|
||||
$result = curl.exe --fail --silent --show-error `
|
||||
-X POST `
|
||||
-H "Authorization: token $env:BUILD_TOKEN" `
|
||||
-H "Content-Type: application/octet-stream" `
|
||||
-T "$($_.FullName)" `
|
||||
"$uploadUrl" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Upload successful: $filename"
|
||||
} else {
|
||||
Write-Host "WARNING: Upload failed for ${filename}: $result"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Build Sidecar (Linux)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.3)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-sidecar-linux:
|
||||
name: Build Sidecar (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
run: |
|
||||
echo "Building for tag: ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
if command -v uv &> /dev/null; then
|
||||
echo "uv already installed: $(uv --version)"
|
||||
else
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y portaudio19-dev
|
||||
|
||||
- name: Build sidecar (CPU)
|
||||
run: |
|
||||
uv sync --no-sources
|
||||
# PyPI's default torch on Linux includes CUDA (~800MB).
|
||||
# Replace with CPU-only torch from the dedicated index.
|
||||
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
|
||||
.venv/bin/pyinstaller local-transcription-headless.spec
|
||||
|
||||
- name: Package sidecar (CPU)
|
||||
run: |
|
||||
cd dist/local-transcription-backend && zip -9 -r ../../sidecar-linux-x86_64-cpu.zip .
|
||||
|
||||
- name: Upload to sidecar release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
sudo apt-get install -y jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
|
||||
echo "Waiting for sidecar release ${TAG} to be available..."
|
||||
for i in $(seq 1 30); do
|
||||
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}")
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
|
||||
|
||||
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
|
||||
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in sidecar-*.zip; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
@@ -0,0 +1,101 @@
|
||||
name: Build Sidecar (macOS)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.3)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-sidecar-macos:
|
||||
name: Build Sidecar (macOS)
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
RELEASE_TAG: "${{ inputs.tag }}"
|
||||
steps:
|
||||
- name: Show tag
|
||||
run: |
|
||||
echo "Building for tag: ${RELEASE_TAG}"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
if command -v uv &> /dev/null; then
|
||||
echo "uv already installed: $(uv --version)"
|
||||
else
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: brew install portaudio
|
||||
|
||||
- name: Build sidecar (CPU)
|
||||
env:
|
||||
UV_NO_SOURCES: "1"
|
||||
run: |
|
||||
# UV_NO_SOURCES bypasses pyproject.toml's [tool.uv.sources] which forces
|
||||
# torch from the CUDA index (no macOS ARM wheels there).
|
||||
# Default PyPI torch includes MPS (Apple Silicon GPU) support.
|
||||
uv sync
|
||||
.venv/bin/pyinstaller local-transcription-headless.spec
|
||||
|
||||
- name: Package sidecar (CPU)
|
||||
run: |
|
||||
cd dist/local-transcription-backend && zip -r ../../sidecar-macos-aarch64-cpu.zip .
|
||||
|
||||
- name: Upload to sidecar release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
which jq || brew install jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${RELEASE_TAG}"
|
||||
|
||||
echo "Waiting for sidecar release ${TAG} to be available..."
|
||||
for i in $(seq 1 30); do
|
||||
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}")
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
|
||||
|
||||
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
|
||||
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in sidecar-*.zip; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
@@ -0,0 +1,135 @@
|
||||
name: Build Sidecar (Windows)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.3)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build-sidecar-windows:
|
||||
name: Build 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 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: $(uv --version)"
|
||||
} 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: Install 7-Zip
|
||||
shell: powershell
|
||||
run: |
|
||||
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
|
||||
choco install 7zip -y
|
||||
}
|
||||
|
||||
- name: Build sidecar (CPU)
|
||||
shell: powershell
|
||||
run: |
|
||||
$env:UV_NO_SOURCES = "1"
|
||||
uv sync
|
||||
# PyPI's default torch includes CUDA. Replace with CPU-only.
|
||||
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
|
||||
.venv\Scripts\pyinstaller.exe local-transcription-headless.spec
|
||||
|
||||
- name: Package sidecar (CPU)
|
||||
shell: powershell
|
||||
run: |
|
||||
7z a -tzip -mx=9 sidecar-windows-x86_64-cpu.zip .\dist\local-transcription-backend\*
|
||||
|
||||
- name: Upload to sidecar release
|
||||
shell: powershell
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
|
||||
$TAG = $env:RELEASE_TAG
|
||||
Write-Host "Release tag: $TAG"
|
||||
|
||||
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
|
||||
|
||||
for ($i = 1; $i -le 30; $i++) {
|
||||
try {
|
||||
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
|
||||
$RELEASE_ID = $release.id
|
||||
|
||||
if ($RELEASE_ID) {
|
||||
Write-Host "Found sidecar release: $TAG (ID: $RELEASE_ID)"
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Write-Host "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
|
||||
if (-not $RELEASE_ID) {
|
||||
Write-Host "ERROR: Failed to find sidecar release for tag $TAG after 30 attempts."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Get-ChildItem -Path . -Filter "sidecar-*.zip" | ForEach-Object {
|
||||
$filename = $_.Name
|
||||
$encodedName = [System.Uri]::EscapeDataString($filename)
|
||||
$size = [math]::Round($_.Length / 1MB, 1)
|
||||
Write-Host "Uploading $filename ($size MB)..."
|
||||
|
||||
try {
|
||||
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
|
||||
$existing = $assets | Where-Object { $_.name -eq $filename }
|
||||
if ($existing) {
|
||||
Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$uploadUrl = "$REPO_API/releases/$RELEASE_ID/assets?name=$encodedName"
|
||||
$result = curl.exe --fail --silent --show-error `
|
||||
-X POST `
|
||||
-H "Authorization: token $env:BUILD_TOKEN" `
|
||||
-H "Content-Type: application/octet-stream" `
|
||||
-T "$($_.FullName)" `
|
||||
"$uploadUrl" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Upload successful: $filename"
|
||||
} else {
|
||||
Write-Host "WARNING: Upload failed for ${filename}: $result"
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
name: Build Sidecars
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'client/**'
|
||||
- 'server/**'
|
||||
- 'backend/**'
|
||||
- 'pyproject.toml'
|
||||
- 'local-transcription-headless.spec'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
bump-sidecar-version:
|
||||
name: Bump sidecar version and tag
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.version }}
|
||||
tag: ${{ steps.bump.outputs.tag }}
|
||||
has_changes: ${{ steps.check_changes.outputs.has_changes }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Check for backend changes
|
||||
id: check_changes
|
||||
run: |
|
||||
# If triggered by workflow_dispatch, always build
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
# Check if relevant files changed in this commit
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD -- client/ server/ backend/ pyproject.toml local-transcription-headless.spec 2>/dev/null || echo "")
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "Backend changes detected: $CHANGED"
|
||||
else
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "No backend changes detected, skipping sidecar build"
|
||||
fi
|
||||
|
||||
- name: Configure git
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
run: |
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.local"
|
||||
|
||||
- name: Bump sidecar patch version
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
id: bump
|
||||
run: |
|
||||
# Read current version from pyproject.toml
|
||||
CURRENT=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "Current sidecar version: ${CURRENT}"
|
||||
|
||||
# Increment patch number
|
||||
MAJOR=$(echo "${CURRENT}" | cut -d. -f1)
|
||||
MINOR=$(echo "${CURRENT}" | cut -d. -f2)
|
||||
PATCH=$(echo "${CURRENT}" | cut -d. -f3)
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
||||
echo "New sidecar version: ${NEW_VERSION}"
|
||||
|
||||
# Update pyproject.toml
|
||||
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
|
||||
|
||||
# Update version.py
|
||||
sed -i "s/__version__ = \"${CURRENT}\"/__version__ = \"${NEW_VERSION}\"/" version.py
|
||||
sed -i "s/__version_info__ = .*/__version_info__ = (${MAJOR}, ${MINOR}, ${NEW_PATCH})/" version.py
|
||||
|
||||
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "tag=sidecar-v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and tag
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.bump.outputs.version }}"
|
||||
TAG="${{ steps.bump.outputs.tag }}"
|
||||
git add pyproject.toml version.py
|
||||
git commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]"
|
||||
git tag "${TAG}"
|
||||
|
||||
REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|")
|
||||
git pull --rebase "${REMOTE_URL}" main || true
|
||||
git push "${REMOTE_URL}" HEAD:main
|
||||
git push "${REMOTE_URL}" "${TAG}"
|
||||
|
||||
- name: Create Gitea release
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ steps.bump.outputs.tag }}"
|
||||
VERSION="${{ steps.bump.outputs.version }}"
|
||||
RELEASE_NAME="Sidecar v${VERSION}"
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated sidecar build.\", \"draft\": false, \"prerelease\": false}" \
|
||||
"${REPO_API}/releases"
|
||||
echo "Created release: ${RELEASE_NAME}"
|
||||
|
||||
# ── Linux sidecar (CUDA + CPU) ──
|
||||
|
||||
build-sidecar-linux:
|
||||
name: Build Sidecar (Linux)
|
||||
needs: bump-sidecar-version
|
||||
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
if command -v uv &> /dev/null; then
|
||||
echo "uv already installed: $(uv --version)"
|
||||
else
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y portaudio19-dev
|
||||
|
||||
- name: Build sidecar (CUDA)
|
||||
run: |
|
||||
uv sync
|
||||
uv run pyinstaller local-transcription-headless.spec
|
||||
|
||||
- name: Package sidecar (CUDA)
|
||||
run: |
|
||||
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cuda.zip .
|
||||
|
||||
- name: Build sidecar (CPU)
|
||||
run: |
|
||||
rm -rf dist/local-transcription-backend build/
|
||||
# Install CPU-only PyTorch
|
||||
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
|
||||
uv run pyinstaller local-transcription-headless.spec
|
||||
|
||||
- name: Package sidecar (CPU)
|
||||
run: |
|
||||
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cpu.zip .
|
||||
|
||||
- name: Upload to sidecar release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
sudo apt-get install -y jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ needs.bump-sidecar-version.outputs.tag }}"
|
||||
|
||||
echo "Waiting for sidecar release ${TAG} to be available..."
|
||||
for i in $(seq 1 30); do
|
||||
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}")
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
|
||||
|
||||
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
|
||||
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in sidecar-*.zip; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
|
||||
# ── Windows sidecar (CUDA + CPU) ──
|
||||
|
||||
build-sidecar-windows:
|
||||
name: Build Sidecar (Windows)
|
||||
needs: bump-sidecar-version
|
||||
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
shell: powershell
|
||||
run: |
|
||||
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
||||
Write-Host "uv already installed: $(uv --version)"
|
||||
} else {
|
||||
irm https://astral.sh/uv/install.ps1 | iex
|
||||
echo "$env:USERPROFILE\.local\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
}
|
||||
|
||||
- name: Set up Python
|
||||
shell: powershell
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install 7-Zip
|
||||
shell: powershell
|
||||
run: |
|
||||
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
|
||||
choco install 7zip -y
|
||||
}
|
||||
|
||||
- name: Build sidecar (CUDA)
|
||||
shell: powershell
|
||||
run: |
|
||||
uv sync
|
||||
uv run pyinstaller local-transcription-headless.spec
|
||||
|
||||
- name: Package sidecar (CUDA)
|
||||
shell: powershell
|
||||
run: |
|
||||
7z a -tzip -mx=5 sidecar-windows-x86_64-cuda.zip .\dist\local-transcription-backend\*
|
||||
|
||||
- name: Build sidecar (CPU)
|
||||
shell: powershell
|
||||
run: |
|
||||
Remove-Item -Recurse -Force dist\local-transcription-backend, build -ErrorAction SilentlyContinue
|
||||
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
|
||||
uv run pyinstaller local-transcription-headless.spec
|
||||
|
||||
- name: Package sidecar (CPU)
|
||||
shell: powershell
|
||||
run: |
|
||||
7z a -tzip -mx=5 sidecar-windows-x86_64-cpu.zip .\dist\local-transcription-backend\*
|
||||
|
||||
- name: Upload to sidecar release
|
||||
shell: powershell
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
|
||||
$TAG = "${{ needs.bump-sidecar-version.outputs.tag }}"
|
||||
|
||||
Write-Host "Waiting for sidecar release ${TAG} to be available..."
|
||||
$RELEASE_ID = $null
|
||||
|
||||
for ($i = 1; $i -le 30; $i++) {
|
||||
try {
|
||||
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
|
||||
$RELEASE_ID = $release.id
|
||||
|
||||
if ($RELEASE_ID) {
|
||||
Write-Host "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Write-Host "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
|
||||
if (-not $RELEASE_ID) {
|
||||
Write-Host "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Get-ChildItem -Path . -Filter "sidecar-*.zip" | ForEach-Object {
|
||||
$filename = $_.Name
|
||||
$encodedName = [System.Uri]::EscapeDataString($filename)
|
||||
$size = [math]::Round($_.Length / 1MB, 1)
|
||||
Write-Host "Uploading ${filename} (${size} MB)..."
|
||||
|
||||
try {
|
||||
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
|
||||
$existing = $assets | Where-Object { $_.name -eq $filename }
|
||||
if ($existing) {
|
||||
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
|
||||
$result = curl.exe --fail --silent --show-error `
|
||||
-X POST `
|
||||
-H "Authorization: token $env:BUILD_TOKEN" `
|
||||
-H "Content-Type: application/octet-stream" `
|
||||
-T "$($_.FullName)" `
|
||||
"$uploadUrl" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Upload successful: ${filename}"
|
||||
} else {
|
||||
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
|
||||
}
|
||||
}
|
||||
|
||||
# ── macOS sidecar (CPU only — no CUDA on macOS) ──
|
||||
|
||||
build-sidecar-macos:
|
||||
name: Build Sidecar (macOS)
|
||||
needs: bump-sidecar-version
|
||||
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
if command -v uv &> /dev/null; then
|
||||
echo "uv already installed: $(uv --version)"
|
||||
else
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install system dependencies
|
||||
run: brew install portaudio
|
||||
|
||||
- name: Build sidecar (CPU)
|
||||
run: |
|
||||
# Install CPU-only PyTorch for macOS (MPS support included in default torch)
|
||||
uv sync
|
||||
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
|
||||
uv run pyinstaller local-transcription-headless.spec
|
||||
|
||||
- name: Package sidecar (CPU)
|
||||
run: |
|
||||
cd dist/local-transcription-backend && zip -r ../../sidecar-macos-aarch64-cpu.zip .
|
||||
|
||||
- name: Upload to sidecar release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
which jq || brew install jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ needs.bump-sidecar-version.outputs.tag }}"
|
||||
|
||||
echo "Waiting for sidecar release ${TAG} to be available..."
|
||||
for i in $(seq 1 30); do
|
||||
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}")
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
|
||||
|
||||
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
|
||||
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in sidecar-*.zip; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
@@ -0,0 +1,102 @@
|
||||
name: Cleanup Old Releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
keep_app_releases:
|
||||
description: 'Number of app releases to keep'
|
||||
required: false
|
||||
default: '3'
|
||||
keep_sidecar_releases:
|
||||
description: 'Number of sidecar releases to keep'
|
||||
required: false
|
||||
default: '2'
|
||||
dry_run:
|
||||
description: 'Dry run (show what would be deleted without deleting)'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Cleanup Old Releases
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Cleanup releases
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
KEEP_APP="${{ inputs.keep_app_releases }}"
|
||||
KEEP_SIDECAR="${{ inputs.keep_sidecar_releases }}"
|
||||
DRY_RUN="${{ inputs.dry_run }}"
|
||||
|
||||
echo "=== Cleanup Configuration ==="
|
||||
echo "Keep app releases: ${KEEP_APP}"
|
||||
echo "Keep sidecar releases: ${KEEP_SIDECAR}"
|
||||
echo "Dry run: ${DRY_RUN}"
|
||||
echo ""
|
||||
|
||||
# Fetch all releases
|
||||
ALL_RELEASES=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases?limit=50")
|
||||
|
||||
# ── App releases (v* tags, not sidecar-v*) ──
|
||||
echo "=== App Releases ==="
|
||||
APP_RELEASES=$(echo "$ALL_RELEASES" | jq -c '[.[] | select(.tag_name | startswith("v")) | select(.tag_name | startswith("sidecar") | not)]')
|
||||
APP_TOTAL=$(echo "$APP_RELEASES" | jq 'length')
|
||||
echo "Found ${APP_TOTAL} app releases, keeping ${KEEP_APP}"
|
||||
|
||||
if [ "$APP_TOTAL" -gt "$KEEP_APP" ]; then
|
||||
echo "$APP_RELEASES" | jq -c ".[$KEEP_APP:][]" | while read -r release; do
|
||||
ID=$(echo "$release" | jq -r '.id')
|
||||
TAG=$(echo "$release" | jq -r '.tag_name')
|
||||
SIZE=$(echo "$release" | jq '[.assets[]?.size // 0] | add // 0')
|
||||
SIZE_MB=$(echo "scale=1; $SIZE / 1048576" | bc 2>/dev/null || echo "?")
|
||||
|
||||
# Protect v1.4.0 (last pre-Tauri release)
|
||||
if [ "$TAG" = "v1.4.0" ]; then
|
||||
echo " PROTECT ${TAG} (${SIZE_MB} MB)"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " WOULD DELETE ${TAG} (ID: ${ID}, ${SIZE_MB} MB)"
|
||||
else
|
||||
echo " DELETING ${TAG} (ID: ${ID}, ${SIZE_MB} MB)..."
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${ID}"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " Nothing to clean up"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ── Sidecar releases (sidecar-v* tags) ──
|
||||
echo "=== Sidecar Releases ==="
|
||||
SIDECAR_RELEASES=$(echo "$ALL_RELEASES" | jq -c '[.[] | select(.tag_name | startswith("sidecar-v"))]')
|
||||
SIDECAR_TOTAL=$(echo "$SIDECAR_RELEASES" | jq 'length')
|
||||
echo "Found ${SIDECAR_TOTAL} sidecar releases, keeping ${KEEP_SIDECAR}"
|
||||
|
||||
if [ "$SIDECAR_TOTAL" -gt "$KEEP_SIDECAR" ]; then
|
||||
echo "$SIDECAR_RELEASES" | jq -c ".[$KEEP_SIDECAR:][]" | while read -r release; do
|
||||
ID=$(echo "$release" | jq -r '.id')
|
||||
TAG=$(echo "$release" | jq -r '.tag_name')
|
||||
SIZE=$(echo "$release" | jq '[.assets[]?.size // 0] | add // 0')
|
||||
SIZE_MB=$(echo "scale=1; $SIZE / 1048576" | bc 2>/dev/null || echo "?")
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo " WOULD DELETE ${TAG} (ID: ${ID}, ${SIZE_MB} MB)"
|
||||
else
|
||||
echo " DELETING ${TAG} (ID: ${ID}, ${SIZE_MB} MB)..."
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${ID}"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo " Nothing to clean up"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
+47
-234
@@ -1,17 +1,41 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
name: Bump version and tag
|
||||
test:
|
||||
name: Run Tests
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.bump.outputs.new_version }}
|
||||
tag: ${{ steps.bump.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install npm deps
|
||||
run: npm ci
|
||||
|
||||
- name: Frontend tests
|
||||
run: npx vitest run
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Python tests
|
||||
run: |
|
||||
uv venv .testvenv
|
||||
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
|
||||
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
|
||||
|
||||
bump-version:
|
||||
name: Bump version and tag
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -23,13 +47,10 @@ jobs:
|
||||
git config user.email "actions@gitea.local"
|
||||
|
||||
- name: Bump patch version
|
||||
id: bump
|
||||
run: |
|
||||
# Read current version from package.json
|
||||
CURRENT=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
|
||||
echo "Current version: ${CURRENT}"
|
||||
|
||||
# Increment patch number
|
||||
MAJOR=$(echo "${CURRENT}" | cut -d. -f1)
|
||||
MINOR=$(echo "${CURRENT}" | cut -d. -f2)
|
||||
PATCH=$(echo "${CURRENT}" | cut -d. -f3)
|
||||
@@ -37,264 +58,56 @@ jobs:
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
||||
echo "New version: ${NEW_VERSION}"
|
||||
|
||||
# Update package.json
|
||||
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json
|
||||
|
||||
# Update src-tauri/tauri.conf.json
|
||||
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" src-tauri/tauri.conf.json
|
||||
|
||||
# Update src-tauri/Cargo.toml
|
||||
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" src-tauri/Cargo.toml
|
||||
|
||||
# Update version.py
|
||||
sed -i "s/__version__ = \"${CURRENT}\"/__version__ = \"${NEW_VERSION}\"/" version.py
|
||||
sed -i "s/__version_info__ = .*/__version_info__ = (${MAJOR}, ${MINOR}, ${NEW_PATCH})/" version.py
|
||||
|
||||
echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "tag=v${NEW_VERSION}" >> $GITHUB_OUTPUT
|
||||
# Write to env file instead of step outputs (avoids act runner bug)
|
||||
echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_ENV
|
||||
echo "RELEASE_TAG=v${NEW_VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Commit and tag
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.bump.outputs.new_version }}"
|
||||
git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml version.py
|
||||
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
|
||||
git tag "v${NEW_VERSION}"
|
||||
git tag "${RELEASE_TAG}"
|
||||
|
||||
REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|")
|
||||
git pull --rebase "${REMOTE_URL}" main || true
|
||||
git push "${REMOTE_URL}" HEAD:main
|
||||
git push "${REMOTE_URL}" "v${NEW_VERSION}"
|
||||
git push "${REMOTE_URL}" "${RELEASE_TAG}"
|
||||
|
||||
- name: Create Gitea release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ steps.bump.outputs.tag }}"
|
||||
RELEASE_NAME="Local Transcription ${TAG}"
|
||||
RELEASE_NAME="Local Transcription ${RELEASE_TAG}"
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated build.\", \"draft\": false, \"prerelease\": false}" \
|
||||
-d "{\"tag_name\": \"${RELEASE_TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated build.\", \"draft\": false, \"prerelease\": false}" \
|
||||
"${REPO_API}/releases"
|
||||
echo "Created release: ${RELEASE_NAME}"
|
||||
|
||||
# ── Platform builds (run after version bump) ──
|
||||
|
||||
build-linux:
|
||||
name: Build App (Linux)
|
||||
needs: bump-version
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump-version.outputs.tag }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Rust stable
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils rpm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Upload to release
|
||||
- name: Trigger per-OS app builds
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
sudo apt-get install -y jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ needs.bump-version.outputs.tag }}"
|
||||
echo "Release tag: ${TAG}"
|
||||
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Failed to find release for tag ${TAG}."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
|
||||
find src-tauri/target/release/bundle -type f \( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) | while IFS= read -r file; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
for workflow in build-app-linux.yml build-app-windows.yml build-app-macos.yml; do
|
||||
echo "Dispatching ${workflow} for ${RELEASE_TAG}..."
|
||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/dispatch_resp.txt -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
|
||||
build-windows:
|
||||
name: Build App (Windows)
|
||||
needs: bump-version
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump-version.outputs.tag }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Rust stable
|
||||
shell: powershell
|
||||
run: |
|
||||
if (Get-Command rustup -ErrorAction SilentlyContinue) {
|
||||
rustup default stable
|
||||
} else {
|
||||
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable
|
||||
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
}
|
||||
|
||||
- name: Install npm dependencies
|
||||
shell: powershell
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
shell: powershell
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Upload to release
|
||||
shell: powershell
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
|
||||
$TAG = "${{ needs.bump-version.outputs.tag }}"
|
||||
Write-Host "Release tag: ${TAG}"
|
||||
|
||||
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
|
||||
$RELEASE_ID = $release.id
|
||||
Write-Host "Release ID: ${RELEASE_ID}"
|
||||
|
||||
Get-ChildItem -Path src-tauri\target\release\bundle -Recurse -Include *.msi,*-setup.exe | ForEach-Object {
|
||||
$filename = $_.Name
|
||||
$encodedName = [System.Uri]::EscapeDataString($filename)
|
||||
$size = [math]::Round($_.Length / 1MB, 1)
|
||||
Write-Host "Uploading ${filename} (${size} MB)..."
|
||||
|
||||
try {
|
||||
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
|
||||
$existing = $assets | Where-Object { $_.name -eq $filename }
|
||||
if ($existing) {
|
||||
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
|
||||
$result = curl.exe --fail --silent --show-error `
|
||||
-X POST `
|
||||
-H "Authorization: token $env:BUILD_TOKEN" `
|
||||
-H "Content-Type: application/octet-stream" `
|
||||
-T "$($_.FullName)" `
|
||||
"$uploadUrl" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Upload successful: ${filename}"
|
||||
} else {
|
||||
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
|
||||
}
|
||||
}
|
||||
|
||||
build-macos:
|
||||
name: Build App (macOS)
|
||||
needs: bump-version
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
NODE_VERSION: "20"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump-version.outputs.tag }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Rust stable
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install system dependencies
|
||||
run: brew install --quiet create-dmg || true
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Upload to release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
which jq || brew install jq
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
TAG="${{ needs.bump-version.outputs.tag }}"
|
||||
echo "Release tag: ${TAG}"
|
||||
|
||||
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
|
||||
|
||||
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
|
||||
echo "ERROR: Failed to find release for tag ${TAG}."
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
|
||||
find src-tauri/target/release/bundle -type f -name "*.dmg" | while IFS= read -r file; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"tag\": \"${RELEASE_TAG}\"}}" \
|
||||
"${REPO_API}/actions/workflows/${workflow}/dispatches")
|
||||
echo " -> HTTP ${HTTP_CODE}"
|
||||
if [ "$HTTP_CODE" != "204" ]; then cat /tmp/dispatch_resp.txt; echo ""; fi
|
||||
done
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
name: Sidecar Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Python tests
|
||||
run: |
|
||||
uv venv .testvenv
|
||||
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
|
||||
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
|
||||
|
||||
bump-sidecar-version:
|
||||
name: Bump sidecar version and tag
|
||||
needs: test
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.local"
|
||||
|
||||
- name: Bump sidecar patch version
|
||||
run: |
|
||||
CURRENT=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "Current sidecar version: ${CURRENT}"
|
||||
|
||||
MAJOR=$(echo "${CURRENT}" | cut -d. -f1)
|
||||
MINOR=$(echo "${CURRENT}" | cut -d. -f2)
|
||||
PATCH=$(echo "${CURRENT}" | cut -d. -f3)
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
||||
echo "New sidecar version: ${NEW_VERSION}"
|
||||
|
||||
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
|
||||
|
||||
# Write to env file instead of step outputs (avoids act runner bug)
|
||||
echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_ENV
|
||||
echo "RELEASE_TAG=sidecar-v${NEW_VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Commit and tag
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
git add pyproject.toml
|
||||
git commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]"
|
||||
git tag "${RELEASE_TAG}"
|
||||
|
||||
REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|")
|
||||
git pull --rebase "${REMOTE_URL}" main || true
|
||||
git push "${REMOTE_URL}" HEAD:main
|
||||
git push "${REMOTE_URL}" "${RELEASE_TAG}"
|
||||
|
||||
- name: Create Gitea release
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
RELEASE_NAME="Sidecar v${NEW_VERSION}"
|
||||
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${RELEASE_TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated sidecar build.\", \"draft\": false, \"prerelease\": false}" \
|
||||
"${REPO_API}/releases"
|
||||
echo "Created release: ${RELEASE_NAME}"
|
||||
|
||||
- name: Trigger per-OS sidecar builds
|
||||
env:
|
||||
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
for workflow in build-sidecar-linux.yml build-sidecar-windows.yml build-sidecar-macos.yml build-sidecar-cloud.yml; do
|
||||
echo "Dispatching ${workflow} for ${RELEASE_TAG}..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"tag\": \"${RELEASE_TAG}\"}}" \
|
||||
"${REPO_API}/actions/workflows/${workflow}/dispatches")
|
||||
echo " -> HTTP ${HTTP_CODE}"
|
||||
done
|
||||
|
||||
# NOTE: Automatic cleanup disabled -- it races with async builds.
|
||||
# Clean up old releases manually from the Gitea UI when needed.
|
||||
@@ -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
|
||||
+5
-2
@@ -10,8 +10,8 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
@@ -63,3 +63,6 @@ dist/
|
||||
|
||||
# Tauri
|
||||
src-tauri/target/
|
||||
|
||||
# Windows NTFS alternate data streams
|
||||
*:Zone.Identifier
|
||||
|
||||
Binary file not shown.
@@ -4,52 +4,114 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Local Transcription is a desktop application for real-time speech-to-text transcription designed for streamers. It uses Whisper models (via faster-whisper) to transcribe audio locally with optional multi-user server synchronization.
|
||||
Local Transcription is a cross-platform desktop application for real-time speech-to-text transcription designed for streamers. It supports local Whisper models and cloud-based Deepgram transcription, with OBS browser source integration and optional multi-user sync.
|
||||
|
||||
**Architecture:** Two-process model — a Tauri v2 shell (Svelte 5 frontend) communicates with a headless Python backend (sidecar) via REST API and WebSocket.
|
||||
|
||||
**Key Features:**
|
||||
- Standalone desktop GUI (PySide6/Qt)
|
||||
- Local transcription with CPU/GPU support
|
||||
- Built-in web server for OBS browser source integration
|
||||
- Optional Node.js-based multi-user server for syncing transcriptions across users
|
||||
- Noise suppression and Voice Activity Detection (VAD)
|
||||
- Cross-platform builds (Linux/Windows) with PyInstaller
|
||||
- Cross-platform desktop app (Windows, macOS, Linux) via Tauri v2 + Svelte 5
|
||||
- Headless Python backend with FastAPI control API
|
||||
- Dual transcription modes: local Whisper or cloud Deepgram (managed/BYOK)
|
||||
- Built-in web server for OBS browser source at `http://localhost:8080`
|
||||
- Optional multi-user sync via Node.js server
|
||||
- CUDA, MPS (Apple Silicon), and CPU support
|
||||
- Auto-updates, custom fonts, configurable colors
|
||||
|
||||
> **Legacy GUI:** The original PySide6/Qt GUI (`main.py`, `gui/`) still works during the transition. New features should target the Tauri frontend and headless backend.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
local-transcription/
|
||||
├── client/ # Core transcription logic
|
||||
│ ├── audio_capture.py # Audio input and buffering
|
||||
│ ├── transcription_engine.py # Whisper model integration
|
||||
├── src/ # Svelte 5 frontend (Tauri UI)
|
||||
│ ├── App.svelte # Main app shell
|
||||
│ ├── app.css # Global dark theme styles
|
||||
│ ├── main.ts # Svelte mount point
|
||||
│ ├── lib/components/ # UI components
|
||||
│ │ ├── Header.svelte # Title bar + settings button
|
||||
│ │ ├── StatusBar.svelte # State indicator, device, user info
|
||||
│ │ ├── Controls.svelte # Start/Stop, Clear, Save buttons
|
||||
│ │ ├── TranscriptionDisplay.svelte # Scrolling transcript view
|
||||
│ │ └── Settings.svelte # Full settings modal (all sections)
|
||||
│ └── lib/stores/ # Svelte 5 reactive stores ($state/$derived)
|
||||
│ ├── backend.ts # WebSocket + REST API client
|
||||
│ ├── config.ts # App configuration fetch/update
|
||||
│ └── transcriptions.ts # Transcript data management
|
||||
├── src-tauri/ # Tauri v2 Rust shell
|
||||
│ ├── src/lib.rs # Plugin registration (shell, dialog, process)
|
||||
│ ├── src/main.rs # Entry point
|
||||
│ ├── tauri.conf.json # Window, bundle, plugin config
|
||||
│ └── Cargo.toml # Rust dependencies
|
||||
├── backend/ # Headless Python backend (the sidecar)
|
||||
│ ├── app_controller.py # Core orchestration (engine, sync, config)
|
||||
│ ├── api_server.py # FastAPI REST endpoints + /ws/control
|
||||
│ └── main_headless.py # Headless entry point (prints JSON to stdout)
|
||||
├── client/ # Core transcription modules (used by backend)
|
||||
│ ├── audio_capture.py # Audio input handling
|
||||
│ ├── transcription_engine_realtime.py # RealtimeSTT / Whisper engine
|
||||
│ ├── deepgram_transcription.py # Deepgram WebSocket cloud transcription
|
||||
│ ├── noise_suppression.py # VAD and noise reduction
|
||||
│ ├── device_utils.py # CPU/GPU device management
|
||||
│ ├── config.py # Configuration management
|
||||
│ └── server_sync.py # Multi-user server sync client
|
||||
├── gui/ # Desktop application UI
|
||||
│ ├── main_window_qt.py # Main application window (PySide6)
|
||||
│ ├── settings_dialog_qt.py # Settings dialog (PySide6)
|
||||
│ ├── device_utils.py # CPU/GPU/MPS detection
|
||||
│ ├── config.py # YAML config management (~/.local-transcription/)
|
||||
│ ├── server_sync.py # Multi-user server sync client
|
||||
│ ├── instance_lock.py # Single-instance PID lock
|
||||
│ └── update_checker.py # Gitea release update checker
|
||||
├── gui/ # Legacy PySide6/Qt GUI (still functional)
|
||||
│ ├── main_window_qt.py # Main window (orchestration lives here in legacy)
|
||||
│ ├── settings_dialog_qt.py # Settings dialog
|
||||
│ └── transcription_display_qt.py # Display widget
|
||||
├── server/ # Web display servers
|
||||
│ ├── web_display.py # FastAPI server for OBS browser source (local)
|
||||
│ └── nodejs/ # Optional multi-user Node.js server
|
||||
│ ├── server.js # Multi-user sync server with WebSocket
|
||||
│ ├── package.json # Node.js dependencies
|
||||
│ └── README.md # Server deployment documentation
|
||||
├── config/ # Example configuration files
|
||||
│ └── default_config.yaml # Default settings template
|
||||
├── main.py # GUI application entry point
|
||||
├── server/
|
||||
│ ├── web_display.py # FastAPI OBS display server (WebSocket + HTML)
|
||||
│ └── nodejs/ # Optional multi-user sync server
|
||||
├── .gitea/workflows/ # CI/CD
|
||||
│ ├── release.yml # Coordinator: version bump, tag, release creation
|
||||
│ ├── build-app-linux.yml # Linux Tauri app build (triggered by v* tag)
|
||||
│ ├── build-app-windows.yml # Windows Tauri app build (triggered by v* tag)
|
||||
│ ├── build-app-macos.yml # macOS Tauri app build (triggered by v* tag)
|
||||
│ ├── sidecar-release.yml # Sidecar coordinator: version bump, tag, release
|
||||
│ ├── build-sidecar-linux.yml # Linux sidecar build (triggered by sidecar-v* tag)
|
||||
│ ├── build-sidecar-windows.yml # Windows sidecar build (triggered by sidecar-v* tag)
|
||||
│ └── build-sidecar-macos.yml # macOS sidecar build (triggered by sidecar-v* tag)
|
||||
├── config/default_config.yaml # Default settings template
|
||||
├── main.py # Legacy PySide6 GUI entry point
|
||||
├── main_cli.py # CLI version for testing
|
||||
└── pyproject.toml # Dependencies and build config
|
||||
├── version.py # Version string (__version__)
|
||||
├── local-transcription.spec # PyInstaller config (legacy, includes PySide6)
|
||||
├── local-transcription-headless.spec # PyInstaller config (headless sidecar, no Qt)
|
||||
├── pyproject.toml # Python deps (uv, CUDA PyTorch index)
|
||||
├── package.json # Node/Tauri deps
|
||||
└── vite.config.ts # Vite build config ($lib alias)
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Installation and Setup
|
||||
### Frontend (Tauri + Svelte)
|
||||
```bash
|
||||
# Install dependencies (creates .venv automatically)
|
||||
# Install npm dependencies
|
||||
npm install
|
||||
|
||||
# Run Tauri in development mode (hot-reload)
|
||||
npm run tauri dev
|
||||
|
||||
# Build frontend only (for testing)
|
||||
npx vite build
|
||||
|
||||
# Type-check Svelte
|
||||
npx svelte-check
|
||||
|
||||
# Check Rust compiles
|
||||
cd src-tauri && cargo check
|
||||
```
|
||||
|
||||
### Backend (Python)
|
||||
```bash
|
||||
# Install Python dependencies
|
||||
uv sync
|
||||
|
||||
# Run the GUI application
|
||||
# Run the headless backend standalone (for development)
|
||||
uv run python -m backend.main_headless --port 8080
|
||||
|
||||
# Run the legacy PySide6 GUI
|
||||
uv run python main.py
|
||||
|
||||
# Run CLI version (headless, for testing)
|
||||
@@ -57,257 +119,163 @@ uv run python main_cli.py
|
||||
|
||||
# List available audio devices
|
||||
uv run python main_cli.py --list-devices
|
||||
|
||||
# Install with CUDA support (if needed)
|
||||
uv pip install torch --index-url https://download.pytorch.org/whl/cu121
|
||||
```
|
||||
|
||||
### Building Executables
|
||||
### Building
|
||||
```bash
|
||||
# Linux (includes CUDA support - works on both GPU and CPU systems)
|
||||
./build.sh
|
||||
# Build Tauri app (produces platform installer)
|
||||
npm run tauri build
|
||||
|
||||
# Windows (includes CUDA support - works on both GPU and CPU systems)
|
||||
build.bat
|
||||
# Build headless Python sidecar (no PySide6)
|
||||
uv run pyinstaller local-transcription-headless.spec
|
||||
# Output: dist/local-transcription-backend/
|
||||
|
||||
# Manual build with PyInstaller
|
||||
uv sync # Install dependencies (includes CUDA PyTorch)
|
||||
uv pip uninstall -q enum34 # Remove incompatible enum34 package
|
||||
# Build legacy PySide6 app
|
||||
uv run pyinstaller local-transcription.spec
|
||||
# Or use: ./build.sh (Linux) / build.bat (Windows)
|
||||
```
|
||||
|
||||
**Important:** All builds include CUDA support via `pyproject.toml` configuration. CUDA builds can be created on systems without NVIDIA GPUs. The PyTorch CUDA runtime is bundled, and the app automatically falls back to CPU if no GPU is available.
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run component tests
|
||||
uv run python test_components.py
|
||||
|
||||
# Check CUDA availability
|
||||
uv run python check_cuda.py
|
||||
|
||||
# Test web server manually
|
||||
uv run python -m uvicorn server.web_display:app --reload
|
||||
```
|
||||
|
||||
## Architecture
|
||||
## Architecture Details
|
||||
|
||||
### Audio Processing Pipeline
|
||||
### Communication: Tauri <-> Python Backend
|
||||
|
||||
1. **Audio Capture** ([client/audio_capture.py](client/audio_capture.py))
|
||||
- Captures audio from microphone/system using sounddevice
|
||||
- Handles automatic sample rate detection and resampling
|
||||
- Uses chunking with overlap for better transcription quality
|
||||
- Default: 3-second chunks with 0.5s overlap
|
||||
The Svelte frontend connects to the Python backend via two channels:
|
||||
|
||||
2. **Noise Suppression** ([client/noise_suppression.py](client/noise_suppression.py))
|
||||
- Applies noisereduce for background noise reduction
|
||||
- Voice Activity Detection (VAD) using webrtcvad
|
||||
- Skips silent segments to improve performance
|
||||
**REST API** (on port 8081 by default):
|
||||
- `GET /api/status` — app state, device info, version
|
||||
- `POST /api/start` / `POST /api/stop` — transcription control
|
||||
- `GET /api/config` / `PUT /api/config` — read/write settings (dot-notation keys)
|
||||
- `GET /api/audio-devices` / `GET /api/compute-devices` — device enumeration
|
||||
- `POST /api/reload-engine` — reload with new model/device
|
||||
- `GET /api/transcriptions` / `POST /api/clear` — transcript management
|
||||
- `POST /api/save-file` — write text to a file path
|
||||
- `GET /api/check-update` / `POST /api/skip-version` — update management
|
||||
- `POST /api/login` / `POST /api/register` / `GET /api/balance` — managed mode proxy
|
||||
|
||||
3. **Transcription** ([client/transcription_engine.py](client/transcription_engine.py))
|
||||
- Uses faster-whisper for efficient inference
|
||||
- Supports CPU, CUDA, and Apple MPS (Mac)
|
||||
- Models: tiny, base, small, medium, large
|
||||
- Thread-safe model loading with locks
|
||||
**WebSocket** `/ws/control`:
|
||||
- Pushes real-time events: `state_changed`, `transcription`, `preview`, `error`, `credits_low`
|
||||
- Client sends keepalive pings
|
||||
|
||||
4. **Display** ([gui/main_window_qt.py](gui/main_window_qt.py))
|
||||
- PySide6/Qt-based desktop GUI
|
||||
- Real-time transcription display with scrolling
|
||||
- Settings panel with live updates (no restart needed)
|
||||
The OBS display server runs separately on port 8080 (`GET /` for HTML, `WebSocket /ws` for transcriptions).
|
||||
|
||||
### Web Server Architecture
|
||||
### Backend Process Lifecycle
|
||||
|
||||
**Local Web Server** ([server/web_display.py](server/web_display.py))
|
||||
- Always runs when GUI starts (port 8080 by default)
|
||||
- FastAPI with WebSocket for real-time updates
|
||||
- Used for OBS browser source integration
|
||||
- Single-user (displays only local transcriptions)
|
||||
1. `main_headless.py` starts, acquires instance lock, creates `AppController`
|
||||
2. `AppController.initialize()` starts the OBS web server (port 8080) and engine init thread
|
||||
3. `APIServer` wraps the controller with FastAPI routes, runs on port 8081
|
||||
4. Backend prints `{"event": "ready", "port": 8080}` to stdout for Tauri to discover
|
||||
5. On shutdown: engine stopped, web server stopped, lock released
|
||||
|
||||
**Multi-User Server** (Optional - for syncing across multiple users)
|
||||
### Headless Backend vs Legacy GUI
|
||||
|
||||
**Node.js WebSocket Server** ([server/nodejs/](server/nodejs/)) - **RECOMMENDED**
|
||||
- Real-time WebSocket support (< 100ms latency)
|
||||
- Handles 100+ concurrent users
|
||||
- Easy deployment to VPS/cloud hosting (Railway, Heroku, DigitalOcean, or any VPS)
|
||||
- Configurable display options via URL parameters:
|
||||
- `timestamps=true/false` - Show/hide timestamps
|
||||
- `maxlines=50` - Maximum visible lines (prevents scroll bars in OBS)
|
||||
- `fontsize=16` - Font size in pixels
|
||||
- `fontfamily=Arial` - Font family
|
||||
- `fade=10` - Seconds before text fades (0 = never)
|
||||
The `AppController` class (`backend/app_controller.py`) extracts all orchestration logic from `gui/main_window_qt.py` into a Qt-free class. The mapping:
|
||||
|
||||
See [server/nodejs/README.md](server/nodejs/README.md) for deployment instructions
|
||||
| Legacy (MainWindow) | Headless (AppController) |
|
||||
|---------------------|--------------------------|
|
||||
| `_initialize_components()` | `_initialize_engine()` |
|
||||
| `_start_transcription()` | `start_transcription()` |
|
||||
| `_stop_transcription()` | `stop_transcription()` |
|
||||
| `_on_settings_saved()` | `apply_settings()` |
|
||||
| `_reload_engine()` | `reload_engine()` |
|
||||
| `_start_web_server_if_enabled()` | `_start_web_server()` |
|
||||
| `_start_server_sync()` | `_start_server_sync()` |
|
||||
| Qt signals | Callbacks (`on_state_changed`, `on_transcription`, etc.) |
|
||||
|
||||
### Configuration System
|
||||
### Threading Model (Headless)
|
||||
|
||||
- Config stored at `~/.local-transcription/config.yaml`
|
||||
- Managed by [client/config.py](client/config.py)
|
||||
- Settings apply immediately without restart (except model changes)
|
||||
- YAML format with nested keys (e.g., `transcription.model`)
|
||||
- Main thread: Uvicorn (FastAPI) event loop
|
||||
- Engine init thread: Downloads models, initializes VAD
|
||||
- Web server thread: Separate asyncio loop for OBS display
|
||||
- Audio capture: Runs in engine callback threads
|
||||
- All results flow through `AppController` callbacks -> `APIServer` WebSocket broadcast
|
||||
|
||||
### Device Management
|
||||
### Svelte Frontend
|
||||
|
||||
- [client/device_utils.py](client/device_utils.py) handles CPU/GPU detection
|
||||
- Auto-detects CUDA, MPS (Mac), or falls back to CPU
|
||||
- Compute types: float32 (best quality), float16 (GPU), int8 (fastest)
|
||||
- Thread-safe device selection
|
||||
Uses Svelte 5 runes throughout (`$state`, `$derived`, `$effect`, `$props`). No Svelte 4 patterns.
|
||||
|
||||
## Key Implementation Details
|
||||
**Stores** (`src/lib/stores/`):
|
||||
- `backend.ts` — WebSocket connection + REST helpers (`apiGet`, `apiPost`, `apiPut`), auto-reconnect
|
||||
- `config.ts` — fetches/updates config from backend API
|
||||
- `transcriptions.ts` — manages transcript list, listens for `CustomEvent`s from backend store
|
||||
|
||||
### PyInstaller Build Configuration
|
||||
**Key patterns:**
|
||||
- Backend store dispatches `CustomEvent`s on `window` for cross-store communication
|
||||
- Settings component collects all changed values into a `Record<string, any>` with dot-notation keys, sends via `PUT /api/config`
|
||||
- Controls use Tauri dialog plugin for native file save, falls back to blob download
|
||||
|
||||
- [local-transcription.spec](local-transcription.spec) controls build
|
||||
- UPX compression enabled for smaller executables
|
||||
- Hidden imports required for PySide6, faster-whisper, torch
|
||||
- Console mode enabled by default (set `console=False` to hide)
|
||||
## CI/CD
|
||||
|
||||
### Threading Model
|
||||
Eight Gitea Actions workflows in `.gitea/workflows/`, split into coordinators and per-OS builders:
|
||||
|
||||
- Main thread: Qt GUI event loop
|
||||
- Audio thread: Captures and processes audio chunks
|
||||
- Web server thread: Runs FastAPI server
|
||||
- Transcription: Runs in callback thread from audio capture
|
||||
- All transcription results communicated via Qt signals
|
||||
**App release (Tauri):**
|
||||
- **`release.yml`**: Coordinator. Triggers on push to `main`. Auto-bumps version in package.json/tauri.conf.json/Cargo.toml/version.py, commits, tags `v{VERSION}`, creates Gitea release.
|
||||
- **`build-app-linux.yml`**: Triggers on `v*` tag push or `workflow_dispatch`. Builds Tauri app, uploads `.deb`/`.rpm`/`.AppImage`.
|
||||
- **`build-app-windows.yml`**: Triggers on `v*` tag push or `workflow_dispatch`. Builds Tauri app, uploads `.msi`/`*-setup.exe`.
|
||||
- **`build-app-macos.yml`**: Triggers on `v*` tag push or `workflow_dispatch`. Builds Tauri app, uploads `.dmg`.
|
||||
|
||||
### Server Sync (Optional Multi-User Feature)
|
||||
**Sidecar release (Python backend):**
|
||||
- **`sidecar-release.yml`**: Coordinator. Triggers on push to `main` with changes in `client/`, `server/`, `backend/`, `pyproject.toml`, or `local-transcription-headless.spec`. Bumps version in pyproject.toml/version.py, tags `sidecar-v{VERSION}`, creates Gitea release.
|
||||
- **`build-sidecar-linux.yml`**: Triggers on `sidecar-v*` tag push or `workflow_dispatch`. Builds CUDA + CPU sidecars via PyInstaller.
|
||||
- **`build-sidecar-windows.yml`**: Triggers on `sidecar-v*` tag push or `workflow_dispatch`. Builds CUDA + CPU sidecars via PyInstaller.
|
||||
- **`build-sidecar-macos.yml`**: Triggers on `sidecar-v*` tag push or `workflow_dispatch`. Builds CPU-only sidecar via PyInstaller.
|
||||
|
||||
- [client/server_sync.py](client/server_sync.py) handles server communication
|
||||
- Toggle in Settings: "Enable Server Sync"
|
||||
- Sends transcriptions to Node.js server via HTTP POST
|
||||
- Real-time updates via WebSocket to display page
|
||||
- Per-speaker font support (Web-Safe, Google Fonts, Custom uploads)
|
||||
- Falls back gracefully if server unavailable
|
||||
All per-OS build workflows can be re-run independently via `workflow_dispatch` with an optional `tag` input. All require a `BUILD_TOKEN` secret (Gitea API token with release write access).
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Adding a New Setting
|
||||
|
||||
1. Add to [config/default_config.yaml](config/default_config.yaml)
|
||||
2. Update [client/config.py](client/config.py) if validation needed
|
||||
3. Add UI control in [gui/settings_dialog_qt.py](gui/settings_dialog_qt.py)
|
||||
4. Apply setting in relevant component (no restart if possible)
|
||||
5. Emit signal to update display if needed
|
||||
1. Add default to [config/default_config.yaml](config/default_config.yaml)
|
||||
2. Add UI control in [src/lib/components/Settings.svelte](src/lib/components/Settings.svelte)
|
||||
3. Ensure the setting is included in the save handler's config update
|
||||
4. Apply in `AppController.apply_settings()` or the relevant component
|
||||
5. For legacy GUI: also update [gui/settings_dialog_qt.py](gui/settings_dialog_qt.py)
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. Add route in [backend/api_server.py](backend/api_server.py) `_setup_routes()`
|
||||
2. Add supporting logic in [backend/app_controller.py](backend/app_controller.py) if needed
|
||||
3. Call from Svelte via `backendStore.apiGet/apiPost/apiPut`
|
||||
|
||||
### Modifying Transcription Display
|
||||
|
||||
- Local GUI: [gui/transcription_display_qt.py](gui/transcription_display_qt.py)
|
||||
- Local web display (OBS): [server/web_display.py](server/web_display.py) (HTML in `_get_html()`)
|
||||
- Tauri UI: [src/lib/components/TranscriptionDisplay.svelte](src/lib/components/TranscriptionDisplay.svelte)
|
||||
- OBS display: [server/web_display.py](server/web_display.py) (HTML in `_get_html()`)
|
||||
- Multi-user display: [server/nodejs/server.js](server/nodejs/server.js) (display page in `/display` route)
|
||||
|
||||
### Adding a New Model Size
|
||||
|
||||
- Update [client/transcription_engine.py](client/transcription_engine.py)
|
||||
- Add to model selector in [gui/settings_dialog_qt.py](gui/settings_dialog_qt.py)
|
||||
- Update CLI argument choices in [main_cli.py](main_cli.py)
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Core:**
|
||||
- `faster-whisper`: Optimized Whisper inference
|
||||
- `torch`: ML framework (CUDA-enabled via special index)
|
||||
- `PySide6`: Qt6 bindings for GUI
|
||||
- `sounddevice`: Cross-platform audio I/O
|
||||
- `noisereduce`, `webrtcvad`: Audio preprocessing
|
||||
|
||||
**Web Server:**
|
||||
- `fastapi`, `uvicorn`: Web server and ASGI
|
||||
- `websockets`: Real-time communication
|
||||
|
||||
**Build:**
|
||||
- `pyinstaller`: Create standalone executables
|
||||
- `uv`: Fast package manager
|
||||
|
||||
**PyTorch CUDA Index:**
|
||||
- Configured in [pyproject.toml](pyproject.toml) under `[[tool.uv.index]]`
|
||||
- Uses PyTorch's custom wheel repository for CUDA builds
|
||||
- Automatically installed with `uv sync` when using CUDA build scripts
|
||||
**Frontend:** Tauri v2, Svelte 5, Vite, TypeScript
|
||||
**Backend:** Python 3.9+, FastAPI, Uvicorn, RealtimeSTT, faster-whisper, PyTorch (CUDA), sounddevice
|
||||
**Build:** PyInstaller (sidecar), Tauri CLI (app), uv (Python packages)
|
||||
**CI:** Gitea Actions with platform-specific runners
|
||||
|
||||
## Platform-Specific Notes
|
||||
|
||||
### Linux
|
||||
- Uses PulseAudio/ALSA for audio
|
||||
- Build scripts use bash (`.sh` files)
|
||||
- Executable: `dist/LocalTranscription/LocalTranscription`
|
||||
- Tauri needs: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev`, `libappindicator3-dev`, `librsvg2-dev`, `patchelf`
|
||||
- Audio: PulseAudio/ALSA via sounddevice
|
||||
|
||||
### Windows
|
||||
- Uses Windows Audio/WASAPI
|
||||
- Build scripts use batch (`.bat` files)
|
||||
- Executable: `dist\LocalTranscription\LocalTranscription.exe`
|
||||
- Requires Visual C++ Redistributable on target systems
|
||||
- Tauri needs: WebView2 (usually pre-installed on Windows 10+)
|
||||
- Audio: WASAPI via sounddevice
|
||||
|
||||
### Cross-Building
|
||||
- **Cannot cross-compile** - must build on target platform
|
||||
- CI/CD should use platform-specific runners
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Model Loading Issues
|
||||
- Models download to `~/.cache/huggingface/`
|
||||
- First run requires internet connection
|
||||
- Check disk space (models: 75MB-3GB depending on size)
|
||||
|
||||
### Audio Device Issues
|
||||
- Run `uv run python main_cli.py --list-devices`
|
||||
- Check permissions (microphone access)
|
||||
- Try different device indices in settings
|
||||
|
||||
### GPU Not Detected
|
||||
- Run `uv run python check_cuda.py`
|
||||
- Install CUDA drivers (not CUDA toolkit - bundled in build)
|
||||
- Verify PyTorch sees GPU: `python -c "import torch; print(torch.cuda.is_available())"`
|
||||
|
||||
### Web Server Port Conflicts
|
||||
- Default port: 8080
|
||||
- Change in [gui/main_window_qt.py](gui/main_window_qt.py) or config
|
||||
- Use `lsof -i :8080` (Linux) or `netstat -ano | findstr :8080` (Windows)
|
||||
|
||||
## OBS Integration
|
||||
|
||||
### Local Display (Single User)
|
||||
1. Start Local Transcription app
|
||||
2. In OBS: Add "Browser" source
|
||||
3. URL: `http://localhost:8080`
|
||||
4. Set dimensions (e.g., 1920x300)
|
||||
|
||||
### Multi-User Display (Node.js Server)
|
||||
1. Deploy Node.js server (see [server/nodejs/README.md](server/nodejs/README.md))
|
||||
2. Each user configures Server URL: `http://your-server:3000/api/send`
|
||||
3. Enter same room name and passphrase
|
||||
4. In OBS: Add "Browser" source
|
||||
5. URL: `http://your-server:3000/display?room=ROOM&fade=10×tamps=true&maxlines=50&fontsize=16`
|
||||
6. Customize URL parameters as needed:
|
||||
- `timestamps=false` - Hide timestamps
|
||||
- `maxlines=30` - Show max 30 lines (prevents scroll bars)
|
||||
- `fontsize=18` - Larger font
|
||||
- `fontfamily=Courier` - Different font
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
**For Real-Time Transcription:**
|
||||
- Use `tiny` or `base` model (faster)
|
||||
- Enable GPU if available (5-10x faster)
|
||||
- Increase chunk_duration for better accuracy (higher latency)
|
||||
- Decrease chunk_duration for lower latency (less context)
|
||||
- Enable VAD to skip silent audio
|
||||
|
||||
**For Build Size Reduction:**
|
||||
- Don't bundle models (download on demand)
|
||||
- Use CPU-only build if no GPU users
|
||||
- Enable UPX compression (already in spec)
|
||||
|
||||
## Phase Status
|
||||
|
||||
- ✅ **Phase 1**: Standalone desktop application (complete)
|
||||
- ✅ **Web Server**: Local OBS integration (complete)
|
||||
- ✅ **Builds**: PyInstaller executables (complete)
|
||||
- ✅ **Phase 2**: Multi-user Node.js server (complete, optional)
|
||||
- ⏸️ **Phase 3+**: Advanced features (see [NEXT_STEPS.md](NEXT_STEPS.md))
|
||||
### macOS
|
||||
- Tauri needs: Xcode Command Line Tools
|
||||
- Audio: CoreAudio via sounddevice
|
||||
- GPU: MPS (Apple Silicon) detected by `device_utils.py`
|
||||
- `Info.plist` must include `NSMicrophoneUsageDescription` for mic access
|
||||
- No CUDA builds — CPU/MPS only
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [README.md](README.md) - User-facing documentation
|
||||
- [BUILD.md](BUILD.md) - Detailed build instructions
|
||||
- [INSTALL.md](INSTALL.md) - Installation guide
|
||||
- [NEXT_STEPS.md](NEXT_STEPS.md) - Future enhancements
|
||||
- [server/nodejs/README.md](server/nodejs/README.md) - Node.js server setup and deployment
|
||||
- [README.md](README.md) — User-facing documentation
|
||||
- [BUILD.md](BUILD.md) — Detailed build instructions
|
||||
- [INSTALL.md](INSTALL.md) — Installation guide
|
||||
- [server/nodejs/README.md](server/nodejs/README.md) — Node.js server setup
|
||||
|
||||
Binary file not shown.
@@ -1,13 +1,14 @@
|
||||
# Local Transcription
|
||||
|
||||
A real-time speech-to-text desktop application for streamers. Run locally on your machine with GPU or CPU, display transcriptions via OBS browser source, and optionally sync with other users through a multi-user server.
|
||||
A real-time speech-to-text desktop application for streamers. Runs locally on your machine with GPU or CPU, displays transcriptions via OBS browser source, and optionally syncs with other users through a multi-user server.
|
||||
|
||||
**Version 1.4.0**
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-Time Transcription**: Live speech-to-text using Whisper models with minimal latency
|
||||
- **Standalone Desktop App**: PySide6/Qt GUI that works without any server
|
||||
- **Cross-Platform**: Native desktop app for Windows, macOS, and Linux via [Tauri](https://tauri.app/)
|
||||
- **Dual Transcription Modes**: Local (Whisper) or cloud (Deepgram) with managed billing or BYOK
|
||||
- **CPU & GPU Support**: Automatic detection of CUDA (NVIDIA), MPS (Apple Silicon), or CPU fallback
|
||||
- **Advanced Voice Detection**: Dual-layer VAD (WebRTC + Silero) for accurate speech detection
|
||||
- **OBS Integration**: Built-in web server for browser source capture at `http://localhost:8080`
|
||||
@@ -16,36 +17,70 @@ A real-time speech-to-text desktop application for streamers. Run locally on you
|
||||
- **Customizable Colors**: User-configurable colors for name, text, and background
|
||||
- **Noise Suppression**: Built-in audio preprocessing to reduce background noise
|
||||
- **Auto-Updates**: Automatic update checking with release notes display
|
||||
- **Cross-Platform**: Builds available for Windows and Linux
|
||||
|
||||
## Architecture
|
||||
|
||||
The application uses a two-process architecture:
|
||||
|
||||
1. **Tauri Shell** (Svelte 5 frontend) — lightweight native window (~50MB) rendering the UI
|
||||
2. **Python Backend** (sidecar) — headless process running transcription, audio capture, and the OBS web server
|
||||
|
||||
The Tauri frontend communicates with the Python backend via REST API and WebSocket, following the same pattern as [voice-to-notes](https://repo.anhonesthost.net/MacroPad/voice-to-notes).
|
||||
|
||||
```
|
||||
Tauri App (user launches this)
|
||||
└─ Spawns Python backend as sidecar
|
||||
├─ FastAPI REST API (control endpoints)
|
||||
├─ WebSocket /ws/control (real-time state + transcriptions)
|
||||
├─ OBS web display at http://localhost:8080
|
||||
└─ Transcription engine (Whisper or Deepgram)
|
||||
```
|
||||
|
||||
> **Legacy GUI**: The original PySide6/Qt desktop GUI (`main.py`) still works alongside the new Tauri frontend during the transition period.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Running from Source
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
# Install Python dependencies
|
||||
uv sync
|
||||
|
||||
# Run the application
|
||||
# Run the Tauri app (frontend + backend)
|
||||
npm install
|
||||
npm run tauri dev
|
||||
|
||||
# Or run just the headless backend (for development)
|
||||
uv run python -m backend.main_headless
|
||||
|
||||
# Or run the legacy PySide6 GUI
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
### Using Pre-Built Executables
|
||||
|
||||
Download the latest release from the [releases page](https://repo.anhonesthost.net/streamer-tools/local-transcription/releases) and run the executable for your platform.
|
||||
Download the latest release from the [releases page](https://repo.anhonesthost.net/streamer-tools/local-transcription/releases):
|
||||
|
||||
- **App installer** (Tauri shell): `.msi` (Windows), `.dmg` (macOS), `.deb`/`.rpm`/`.AppImage` (Linux)
|
||||
- **Sidecar** (Python backend): Download the matching `sidecar-*` zip for your platform (CUDA or CPU)
|
||||
|
||||
### Building from Source
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
./build.sh
|
||||
# Output: dist/LocalTranscription/LocalTranscription
|
||||
```
|
||||
# Build the Tauri app
|
||||
npm install
|
||||
npm run tauri build
|
||||
# Output: src-tauri/target/release/bundle/
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
# Build the Python sidecar (headless, no Qt)
|
||||
uv sync
|
||||
uv run pyinstaller local-transcription-headless.spec
|
||||
# Output: dist/local-transcription-backend/
|
||||
|
||||
# Build the legacy PySide6 app (Linux)
|
||||
./build.sh
|
||||
# Build the legacy PySide6 app (Windows)
|
||||
build.bat
|
||||
# Output: dist\LocalTranscription\LocalTranscription.exe
|
||||
```
|
||||
|
||||
For detailed build instructions, see [BUILD.md](BUILD.md).
|
||||
@@ -57,14 +92,23 @@ For detailed build instructions, see [BUILD.md](BUILD.md).
|
||||
1. Launch the application
|
||||
2. Select your microphone from the audio device dropdown
|
||||
3. Choose a Whisper model (smaller = faster, larger = more accurate):
|
||||
- `tiny.en` / `tiny` - Fastest, good for quick captions
|
||||
- `base.en` / `base` - Balanced speed and accuracy
|
||||
- `small.en` / `small` - Better accuracy
|
||||
- `medium.en` / `medium` - High accuracy
|
||||
- `large-v3` - Best accuracy (requires more resources)
|
||||
- `tiny.en` / `tiny` — Fastest, good for quick captions
|
||||
- `base.en` / `base` — Balanced speed and accuracy
|
||||
- `small.en` / `small` — Better accuracy
|
||||
- `medium.en` / `medium` — High accuracy
|
||||
- `large-v3` — Best accuracy (requires more resources)
|
||||
4. Click **Start** to begin transcription
|
||||
5. Transcriptions appear in the main window and at `http://localhost:8080`
|
||||
|
||||
### Remote Transcription (Deepgram)
|
||||
|
||||
Instead of local Whisper models, you can use cloud-based transcription:
|
||||
|
||||
- **Managed mode**: Sign up via the transcription proxy for metered billing
|
||||
- **BYOK mode**: Bring your own Deepgram API key for direct access
|
||||
|
||||
Configure in Settings > Remote Transcription.
|
||||
|
||||
### OBS Browser Source Setup
|
||||
|
||||
1. Start the Local Transcription app
|
||||
@@ -88,7 +132,7 @@ For syncing transcriptions across multiple users (e.g., multi-host streams or tr
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are stored at `~/.local-transcription/config.yaml` and can be modified through the GUI settings panel.
|
||||
Settings are stored at `~/.local-transcription/config.yaml` and can be modified through the GUI settings panel or the REST API.
|
||||
|
||||
### Key Settings
|
||||
|
||||
@@ -100,6 +144,7 @@ Settings are stored at `~/.local-transcription/config.yaml` and can be modified
|
||||
| `transcription.silero_sensitivity` | VAD sensitivity (0-1, lower = more sensitive) | `0.4` |
|
||||
| `transcription.post_speech_silence_duration` | Silence before finalizing (seconds) | `0.3` |
|
||||
| `transcription.continuous_mode` | Fast speaker mode for quick talkers | `false` |
|
||||
| `remote.mode` | Transcription mode (local/managed/byok) | `local` |
|
||||
| `display.show_timestamps` | Show timestamps with transcriptions | `true` |
|
||||
| `display.fade_after_seconds` | Fade out time (0 = never) | `10` |
|
||||
| `display.font_source` | Font type (System Font/Web-Safe/Google Font/Custom File) | `System Font` |
|
||||
@@ -111,70 +156,126 @@ See [config/default_config.yaml](config/default_config.yaml) for all available o
|
||||
|
||||
```
|
||||
local-transcription/
|
||||
├── src/ # Svelte 5 frontend (Tauri UI)
|
||||
│ ├── App.svelte # Main app shell
|
||||
│ ├── lib/components/ # UI components
|
||||
│ │ ├── Header.svelte
|
||||
│ │ ├── StatusBar.svelte
|
||||
│ │ ├── Controls.svelte
|
||||
│ │ ├── TranscriptionDisplay.svelte
|
||||
│ │ └── Settings.svelte
|
||||
│ └── lib/stores/ # Reactive state management
|
||||
│ ├── backend.ts # WebSocket + REST API client
|
||||
│ ├── config.ts # App configuration
|
||||
│ └── transcriptions.ts # Transcription data
|
||||
├── src-tauri/ # Tauri v2 Rust shell
|
||||
│ ├── src/main.rs
|
||||
│ └── tauri.conf.json
|
||||
├── backend/ # Headless Python backend (sidecar)
|
||||
│ ├── app_controller.py # Orchestration logic (engine, sync, config)
|
||||
│ ├── api_server.py # FastAPI REST + WebSocket control API
|
||||
│ └── main_headless.py # Headless entry point
|
||||
├── client/ # Core transcription modules
|
||||
│ ├── audio_capture.py # Audio input handling
|
||||
│ ├── transcription_engine_realtime.py # RealtimeSTT integration
|
||||
│ ├── transcription_engine_realtime.py # RealtimeSTT / Whisper
|
||||
│ ├── deepgram_transcription.py # Deepgram cloud transcription
|
||||
│ ├── noise_suppression.py # VAD and noise reduction
|
||||
│ ├── device_utils.py # CPU/GPU detection
|
||||
│ ├── device_utils.py # CPU/GPU/MPS detection
|
||||
│ ├── config.py # Configuration management
|
||||
│ ├── server_sync.py # Multi-user server client
|
||||
│ └── update_checker.py # Auto-update functionality
|
||||
├── gui/ # Desktop application UI
|
||||
│ ├── main_window_qt.py # Main application window
|
||||
│ ├── settings_dialog_qt.py # Settings dialog
|
||||
│ └── transcription_display_qt.py # Display widget
|
||||
├── gui/ # Legacy PySide6/Qt GUI
|
||||
│ ├── main_window_qt.py
|
||||
│ ├── settings_dialog_qt.py
|
||||
│ └── transcription_display_qt.py
|
||||
├── server/ # Web servers
|
||||
│ ├── web_display.py # Local FastAPI server for OBS
|
||||
│ └── nodejs/ # Multi-user sync server
|
||||
│ ├── server.js # Express + WebSocket server
|
||||
│ └── README.md # Deployment instructions
|
||||
├── .gitea/workflows/ # CI/CD
|
||||
│ ├── release.yml # Tauri app builds (all platforms)
|
||||
│ └── build-sidecar.yml # Python sidecar builds (CUDA + CPU)
|
||||
├── config/
|
||||
│ └── default_config.yaml # Default settings template
|
||||
├── main.py # GUI entry point
|
||||
├── main.py # Legacy GUI entry point
|
||||
├── main_cli.py # CLI version (for testing)
|
||||
├── build.sh # Linux build script
|
||||
├── build.bat # Windows build script
|
||||
└── local-transcription.spec # PyInstaller configuration
|
||||
├── local-transcription.spec # PyInstaller config (legacy, with PySide6)
|
||||
├── local-transcription-headless.spec # PyInstaller config (headless sidecar)
|
||||
├── pyproject.toml # Python dependencies
|
||||
└── package.json # Node.js / Tauri dependencies
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Desktop Application
|
||||
### Frontend (Tauri)
|
||||
- **Tauri v2** — Native cross-platform shell (Rust)
|
||||
- **Svelte 5** — Reactive UI framework (TypeScript)
|
||||
- **Vite** — Frontend build tool
|
||||
|
||||
### Backend (Python Sidecar)
|
||||
- **Python 3.9+**
|
||||
- **PySide6** - Qt6 GUI framework
|
||||
- **RealtimeSTT** - Real-time speech-to-text with advanced VAD
|
||||
- **faster-whisper** - Optimized Whisper model inference
|
||||
- **PyTorch** - ML framework (CUDA-enabled)
|
||||
- **sounddevice** - Cross-platform audio capture
|
||||
- **webrtcvad + silero_vad** - Voice activity detection
|
||||
- **noisereduce** - Noise suppression
|
||||
- **FastAPI + Uvicorn** — REST API and WebSocket server
|
||||
- **RealtimeSTT** — Real-time speech-to-text with advanced VAD
|
||||
- **faster-whisper** — Optimized Whisper model inference (CTranslate2)
|
||||
- **PyTorch** — ML framework (CUDA-enabled builds available)
|
||||
- **sounddevice** — Cross-platform audio capture
|
||||
- **webrtcvad + silero_vad** — Voice activity detection
|
||||
|
||||
### Web Servers
|
||||
- **FastAPI + Uvicorn** - Local web display server
|
||||
- **Node.js + Express + WebSocket** - Multi-user sync server
|
||||
### Multi-User Server (Optional)
|
||||
- **Node.js + Express + WebSocket** — Real-time sync server
|
||||
|
||||
### Build Tools
|
||||
- **PyInstaller** - Executable packaging
|
||||
- **uv** - Fast Python package manager
|
||||
### Build & CI/CD
|
||||
- **PyInstaller** — Python sidecar packaging
|
||||
- **Tauri CLI** — App bundling (.msi, .dmg, .deb, .rpm, .AppImage)
|
||||
- **Gitea Actions** — Automated cross-platform builds
|
||||
- **uv** — Fast Python package manager
|
||||
|
||||
## CI/CD
|
||||
|
||||
Two Gitea Actions workflows in `.gitea/workflows/`:
|
||||
|
||||
| Workflow | Trigger | Produces |
|
||||
|----------|---------|----------|
|
||||
| `release.yml` | Push to `main` | Tauri app installers for all platforms |
|
||||
| `build-sidecar.yml` | Changes to `client/`, `server/`, `backend/`, or `pyproject.toml` | Python sidecar zips (CUDA + CPU) |
|
||||
|
||||
Both workflows require a `BUILD_TOKEN` secret in the repo settings (Gitea API token with release write access).
|
||||
|
||||
### Release Artifacts
|
||||
|
||||
| Platform | App Installer | Sidecar (CUDA) | Sidecar (CPU) |
|
||||
|----------|--------------|----------------|---------------|
|
||||
| Linux x86_64 | `.deb`, `.rpm`, `.AppImage` | `sidecar-linux-x86_64-cuda.zip` | `sidecar-linux-x86_64-cpu.zip` |
|
||||
| Windows x86_64 | `.msi`, `-setup.exe` | `sidecar-windows-x86_64-cuda.zip` | `sidecar-windows-x86_64-cpu.zip` |
|
||||
| macOS ARM64 | `.dmg` | — | `sidecar-macos-aarch64-cpu.zip` |
|
||||
|
||||
## System Requirements
|
||||
|
||||
### Minimum
|
||||
- Python 3.9+
|
||||
- 4GB RAM
|
||||
- Any modern CPU
|
||||
|
||||
### Recommended (for real-time performance)
|
||||
### Recommended (for local real-time transcription)
|
||||
- 8GB+ RAM
|
||||
- NVIDIA GPU with CUDA support (for GPU acceleration)
|
||||
- FFmpeg (installed automatically with dependencies)
|
||||
|
||||
### For Building
|
||||
- **Linux**: gcc, Python dev headers
|
||||
- **Windows**: Visual Studio Build Tools, Python dev headers
|
||||
- **Tauri app**: Node.js 20+, Rust stable, platform SDK (see [Tauri prerequisites](https://tauri.app/start/prerequisites/))
|
||||
- **Python sidecar**: Python 3.9+, uv, PyInstaller
|
||||
- **Linux**: `libgtk-3-dev`, `libwebkit2gtk-4.1-dev`, `libappindicator3-dev`, `librsvg2-dev`, `patchelf`
|
||||
- **Windows**: Visual Studio Build Tools, WebView2
|
||||
- **macOS**: Xcode Command Line Tools
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### macOS: "App is damaged and can't be opened"
|
||||
macOS Gatekeeper blocks unsigned applications. Since the app is not yet signed with an Apple Developer certificate, you need to remove the quarantine flag before opening:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/Local Transcription.app"
|
||||
```
|
||||
|
||||
Then open the app normally. You only need to do this once after downloading.
|
||||
|
||||
### Model Loading Issues
|
||||
- Models download automatically on first use to `~/.cache/huggingface/`
|
||||
- First run requires internet connection
|
||||
@@ -185,7 +286,7 @@ local-transcription/
|
||||
# List available audio devices
|
||||
uv run python main_cli.py --list-devices
|
||||
```
|
||||
- Ensure microphone permissions are granted
|
||||
- Ensure microphone permissions are granted (especially on macOS)
|
||||
- Try different device indices in settings
|
||||
|
||||
### GPU Not Detected
|
||||
@@ -193,13 +294,13 @@ uv run python main_cli.py --list-devices
|
||||
# Check CUDA availability
|
||||
uv run python -c "import torch; print(torch.cuda.is_available())"
|
||||
```
|
||||
- Install NVIDIA drivers (CUDA toolkit is bundled)
|
||||
- Install NVIDIA drivers (CUDA toolkit is bundled in CUDA sidecar builds)
|
||||
- The app automatically falls back to CPU if no GPU is available
|
||||
|
||||
### Web Server Port Conflicts
|
||||
- Default port is 8080
|
||||
- Default port is 8080; the app tries ports 8080-8084 automatically
|
||||
- Change in settings or edit config file
|
||||
- Check for conflicts: `lsof -i :8080` (Linux) or `netstat -ano | findstr :8080` (Windows)
|
||||
- Check for conflicts: `lsof -i :8080` (Linux/macOS) or `netstat -ano | findstr :8080` (Windows)
|
||||
|
||||
## Use Cases
|
||||
|
||||
@@ -222,3 +323,5 @@ MIT License
|
||||
- [OpenAI Whisper](https://github.com/openai/whisper) for the speech recognition model
|
||||
- [RealtimeSTT](https://github.com/KoljaB/RealtimeSTT) for real-time transcription capabilities
|
||||
- [faster-whisper](https://github.com/guillaumekln/faster-whisper) for optimized inference
|
||||
- [Tauri](https://tauri.app/) for the cross-platform desktop framework
|
||||
- [Deepgram](https://deepgram.com/) for cloud transcription API
|
||||
|
||||
+30
-4
@@ -99,11 +99,19 @@ class APIServer:
|
||||
|
||||
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):
|
||||
"""Send a message to all connected /ws/control clients."""
|
||||
if not self.control_connections:
|
||||
return
|
||||
|
||||
loop = getattr(self, '_event_loop', None)
|
||||
if loop is None:
|
||||
return
|
||||
|
||||
message = json.dumps(data)
|
||||
disconnected = []
|
||||
|
||||
@@ -111,7 +119,7 @@ class APIServer:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
ws.send_text(message),
|
||||
asyncio.get_event_loop(),
|
||||
loop,
|
||||
)
|
||||
except Exception:
|
||||
disconnected.append(ws)
|
||||
@@ -124,6 +132,10 @@ class APIServer:
|
||||
app = self.app
|
||||
ctrl = self.controller
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
self.set_event_loop(asyncio.get_event_loop())
|
||||
|
||||
# ── Status ─────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/status")
|
||||
@@ -139,14 +151,24 @@ class APIServer:
|
||||
|
||||
@app.post("/api/start")
|
||||
async def start_transcription():
|
||||
success, message = ctrl.start_transcription()
|
||||
import asyncio
|
||||
# Run in thread pool to avoid blocking the event loop
|
||||
# (start_recording can block up to 15s waiting for Deepgram WS)
|
||||
loop = asyncio.get_event_loop()
|
||||
success, message = await loop.run_in_executor(
|
||||
None, ctrl.start_transcription
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
return {"status": "ok", "message": message}
|
||||
|
||||
@app.post("/api/stop")
|
||||
async def stop_transcription():
|
||||
success, message = ctrl.stop_transcription()
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
success, message = await loop.run_in_executor(
|
||||
None, ctrl.stop_transcription
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
return {"status": "ok", "message": message}
|
||||
@@ -211,7 +233,11 @@ class APIServer:
|
||||
|
||||
@app.post("/api/reload-engine")
|
||||
async def reload_engine():
|
||||
success, message = ctrl.reload_engine()
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
success, message = await loop.run_in_executor(
|
||||
None, ctrl.reload_engine
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
return {"status": "ok", "message": message}
|
||||
|
||||
+75
-11
@@ -18,13 +18,18 @@ import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from client.config import Config
|
||||
from client.device_utils import DeviceManager
|
||||
from client.transcription_engine_realtime import RealtimeTranscriptionEngine, TranscriptionResult
|
||||
from client.models import TranscriptionResult
|
||||
from client.deepgram_transcription import DeepgramTranscriptionEngine
|
||||
from client.server_sync import ServerSyncClient
|
||||
from server.web_display import TranscriptionWebServer
|
||||
from version import __version__
|
||||
|
||||
# Heavy imports (torch, RealtimeSTT, faster-whisper) are deferred so
|
||||
# the cloud-only sidecar build can exclude them entirely.
|
||||
# Imported lazily in _initialize_engine() when remote.mode == "local".
|
||||
RealtimeTranscriptionEngine = None
|
||||
DeviceManager = None
|
||||
|
||||
|
||||
class AppState:
|
||||
"""Enum-like class for application states."""
|
||||
@@ -89,7 +94,24 @@ class AppController:
|
||||
|
||||
def __init__(self, config: Optional[Config] = None):
|
||||
self.config = config or Config()
|
||||
self.device_manager = DeviceManager()
|
||||
|
||||
# DeviceManager is only needed for local Whisper mode.
|
||||
# Lazy-import to keep the cloud-only sidecar lightweight.
|
||||
global DeviceManager
|
||||
if DeviceManager is None:
|
||||
try:
|
||||
from client.device_utils import DeviceManager as _DM
|
||||
DeviceManager = _DM
|
||||
except ImportError:
|
||||
DeviceManager = None
|
||||
|
||||
self.device_manager = DeviceManager() if DeviceManager else None
|
||||
self.is_cloud_only = DeviceManager is None
|
||||
|
||||
# If this is the cloud-only sidecar and mode is still "local",
|
||||
# auto-switch to "byok" so the engine doesn't try to load Whisper.
|
||||
if self.is_cloud_only and self.config.get('remote.mode', 'local') == 'local':
|
||||
self.config.set('remote.mode', 'byok')
|
||||
|
||||
# State
|
||||
self._state = AppState.INITIALIZING
|
||||
@@ -243,15 +265,12 @@ class AppController:
|
||||
|
||||
def _initialize_engine(self):
|
||||
"""Initialize the transcription engine in a background thread."""
|
||||
device_config = self.config.get('transcription.device', 'auto')
|
||||
self.device_manager.set_device(device_config)
|
||||
|
||||
audio_device_str = self.config.get('audio.input_device', 'default')
|
||||
audio_device = None if audio_device_str == 'default' else int(audio_device_str)
|
||||
|
||||
model = self.config.get('transcription.model', 'base.en')
|
||||
language = self.config.get('transcription.language', 'en')
|
||||
device = self.device_manager.get_device_for_whisper()
|
||||
device_config = self.config.get('transcription.device', 'auto')
|
||||
compute_type = self.config.get('transcription.compute_type', 'default')
|
||||
|
||||
self.current_model_size = model
|
||||
@@ -284,6 +303,27 @@ class AppController:
|
||||
self.transcription_engine.set_error_callback(self._on_remote_error)
|
||||
self.transcription_engine.set_credits_low_callback(self._on_credits_low)
|
||||
else:
|
||||
# Lazy-import heavy local transcription dependencies
|
||||
global RealtimeTranscriptionEngine
|
||||
if RealtimeTranscriptionEngine is None:
|
||||
try:
|
||||
from client.transcription_engine_realtime import RealtimeTranscriptionEngine as _RTE
|
||||
RealtimeTranscriptionEngine = _RTE
|
||||
except ImportError:
|
||||
# Cloud-only sidecar -- local engine not available
|
||||
self._set_state(
|
||||
AppState.ERROR,
|
||||
"Local transcription not available in this build. "
|
||||
"Please switch to Cloud (Deepgram) mode in Settings."
|
||||
)
|
||||
return
|
||||
|
||||
if self.device_manager:
|
||||
self.device_manager.set_device(device_config)
|
||||
device = self.device_manager.get_device_for_whisper()
|
||||
else:
|
||||
device = "cpu"
|
||||
|
||||
self.transcription_engine = RealtimeTranscriptionEngine(
|
||||
model=model,
|
||||
device=device,
|
||||
@@ -332,6 +372,14 @@ class AppController:
|
||||
device_display = "Unknown"
|
||||
|
||||
self._set_state(AppState.READY, f"Ready | Device: {device_display}")
|
||||
else:
|
||||
# Cloud sidecar with no API key -- show helpful setup message
|
||||
# instead of a scary error. The user needs to enter their key.
|
||||
if self.is_cloud_only:
|
||||
self._set_state(
|
||||
AppState.READY,
|
||||
"Setup needed: Open Settings > Remote Transcription > enter your Deepgram API key"
|
||||
)
|
||||
else:
|
||||
self._set_state(AppState.ERROR, message)
|
||||
|
||||
@@ -348,7 +396,14 @@ class AppController:
|
||||
try:
|
||||
success = self.transcription_engine.start_recording()
|
||||
if not success:
|
||||
return False, "Failed to start recording"
|
||||
import logging
|
||||
# Check if there's a recent error in the logger
|
||||
err_detail = getattr(self.transcription_engine, '_last_error', '')
|
||||
msg = f"Failed to start recording"
|
||||
if err_detail:
|
||||
msg += f": {err_detail}"
|
||||
print(f"ERROR: {msg}")
|
||||
return False, msg
|
||||
|
||||
# Start server sync if enabled
|
||||
if self.config.get('server_sync.enabled', False):
|
||||
@@ -577,12 +632,18 @@ class AppController:
|
||||
if self.config.get('server_sync.enabled', False):
|
||||
self._start_server_sync()
|
||||
|
||||
# Check if model/device changed
|
||||
# Check if model/device/remote mode changed -- any of these require
|
||||
# a full engine reload since they change which engine class is used
|
||||
new_model = self.config.get('transcription.model', 'base.en')
|
||||
new_device = self.config.get('transcription.device', 'auto')
|
||||
new_remote_mode = self.config.get('remote.mode', 'local')
|
||||
current_remote_mode = 'local'
|
||||
if self.transcription_engine:
|
||||
current_remote_mode = getattr(self.transcription_engine, 'mode', 'local')
|
||||
engine_reload_needed = (
|
||||
self.current_model_size != new_model
|
||||
or self.current_device_config != new_device
|
||||
or current_remote_mode != new_remote_mode
|
||||
)
|
||||
|
||||
if engine_reload_needed:
|
||||
@@ -596,7 +657,7 @@ class AppController:
|
||||
host = self.config.get('web_server.host', '127.0.0.1')
|
||||
port = self.actual_web_port or self.config.get('web_server.port', 8080)
|
||||
|
||||
device_info = self.device_manager.get_device_info()
|
||||
device_info = self.device_manager.get_device_info() if self.device_manager else []
|
||||
|
||||
remote_mode = self.config.get('remote.mode', 'local')
|
||||
if remote_mode in ('managed', 'byok') and self.transcription_engine:
|
||||
@@ -640,10 +701,13 @@ class AppController:
|
||||
|
||||
def get_compute_devices(self) -> list[dict]:
|
||||
"""List available compute devices."""
|
||||
device_info = self.device_manager.get_device_info()
|
||||
devices = [{"id": "auto", "name": "Auto-detect"}]
|
||||
if self.device_manager:
|
||||
device_info = self.device_manager.get_device_info()
|
||||
for dev_id, dev_name in device_info:
|
||||
devices.append({"id": dev_id, "name": dev_name})
|
||||
else:
|
||||
devices.append({"id": "cloud", "name": "Cloud (Deepgram)"})
|
||||
return devices
|
||||
|
||||
# ── Update Checking ────────────────────────────────────────────
|
||||
|
||||
@@ -88,11 +88,16 @@ def main():
|
||||
# Create API server wrapping the controller
|
||||
api_server = APIServer(controller)
|
||||
|
||||
# Determine actual port (web server may have shifted if port was in use)
|
||||
actual_port = controller.actual_web_port or args.port
|
||||
# OBS display runs on the configured port, API server on port+1
|
||||
obs_port = controller.actual_web_port or args.port
|
||||
api_port = obs_port + 1
|
||||
|
||||
# Print ready event so Tauri can discover the port
|
||||
print(json.dumps({"event": "ready", "port": actual_port}), flush=True)
|
||||
# Print ready event so Tauri can discover the API port
|
||||
print(json.dumps({
|
||||
"event": "ready",
|
||||
"port": api_port,
|
||||
"obs_port": obs_port,
|
||||
}), flush=True)
|
||||
|
||||
# Run the API server (blocks)
|
||||
import uvicorn
|
||||
@@ -104,7 +109,7 @@ def main():
|
||||
uvicorn.run(
|
||||
api_server.app,
|
||||
host=args.host,
|
||||
port=actual_port + 1, # API on port+1, OBS display on the main port
|
||||
port=api_port,
|
||||
log_level="error",
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,183 @@
|
||||
"""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")
|
||||
# Remote mode must also match (no engine means current mode is 'local')
|
||||
controller.config.set("remote.mode", "local")
|
||||
|
||||
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
|
||||
@@ -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 typing import Optional, Callable
|
||||
|
||||
from client.transcription_engine_realtime import TranscriptionResult
|
||||
from client.models import TranscriptionResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,7 +67,7 @@ class DeepgramTranscriptionEngine:
|
||||
# Audio parameters
|
||||
self.sample_rate: int = 16000
|
||||
self.channels: int = 1
|
||||
self.blocksize: int = 4096
|
||||
self.blocksize: int = 1024 # ~64ms chunks for lower latency streaming
|
||||
|
||||
# Callbacks
|
||||
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
|
||||
@@ -156,17 +156,30 @@ class DeepgramTranscriptionEngine:
|
||||
return True
|
||||
|
||||
self._stop_event.clear()
|
||||
self._ws_connected = threading.Event()
|
||||
self._is_recording = True
|
||||
|
||||
# Start the asyncio event-loop thread (handles WS send/receive)
|
||||
self._thread = threading.Thread(target=self._run_event_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
# Wait for the WebSocket to connect before starting audio capture.
|
||||
# Without this, audio chunks arrive before the WS is open -> broken pipe.
|
||||
if not self._ws_connected.wait(timeout=15):
|
||||
logger.error("Timed out waiting for Deepgram WebSocket connection")
|
||||
print("ERROR: Timed out waiting for Deepgram WebSocket connection")
|
||||
self._last_error = "Timed out connecting to Deepgram"
|
||||
self._is_recording = False
|
||||
self._stop_event.set()
|
||||
return False
|
||||
|
||||
# Start the audio capture stream
|
||||
try:
|
||||
self._start_audio_stream()
|
||||
except Exception as exc:
|
||||
logger.error("Failed to open audio stream: %s", exc)
|
||||
print(f"ERROR: Failed to open audio stream: {exc}")
|
||||
self._last_error = f"Audio stream error: {exc}"
|
||||
self._is_recording = False
|
||||
self._stop_event.set()
|
||||
return False
|
||||
@@ -283,6 +296,11 @@ class DeepgramTranscriptionEngine:
|
||||
if not await self._managed_handshake():
|
||||
return
|
||||
|
||||
# Signal that the WebSocket is connected and ready
|
||||
logger.info("WebSocket connected to Deepgram")
|
||||
if hasattr(self, '_ws_connected'):
|
||||
self._ws_connected.set()
|
||||
|
||||
# Run send and receive concurrently
|
||||
await asyncio.gather(
|
||||
self._send_loop(),
|
||||
@@ -314,6 +332,8 @@ class DeepgramTranscriptionEngine:
|
||||
f"model={self.deepgram_model}"
|
||||
f"&language={self.language}"
|
||||
"&interim_results=true"
|
||||
"&punctuate=true"
|
||||
"&smart_format=true"
|
||||
"&encoding=linear16"
|
||||
f"&sample_rate={self.sample_rate}"
|
||||
f"&channels={self.channels}"
|
||||
@@ -370,10 +390,16 @@ class DeepgramTranscriptionEngine:
|
||||
|
||||
async def _send_loop(self):
|
||||
"""Drain the audio queue and push raw PCM bytes over the WebSocket."""
|
||||
loop = asyncio.get_event_loop()
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
pcm_bytes = self._audio_queue.get(timeout=0.1)
|
||||
except Empty:
|
||||
# Use run_in_executor to avoid blocking the async event loop
|
||||
# (which would stall the receive loop and delay transcriptions)
|
||||
pcm_bytes = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, lambda: self._audio_queue.get(timeout=0.5)),
|
||||
timeout=1.0,
|
||||
)
|
||||
except (Empty, asyncio.TimeoutError):
|
||||
continue
|
||||
|
||||
try:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
class TranscriptionResult:
|
||||
"""Represents a transcription result."""
|
||||
|
||||
def __init__(self, text: str, is_final: bool, timestamp: datetime, user_name: str = ""):
|
||||
"""
|
||||
Initialize transcription result.
|
||||
|
||||
Args:
|
||||
text: Transcribed text
|
||||
is_final: Whether this is a final transcription or realtime preview
|
||||
timestamp: Timestamp of transcription
|
||||
user_name: Name of the user/speaker
|
||||
"""
|
||||
self.text = text.strip()
|
||||
self.is_final = is_final
|
||||
self.timestamp = timestamp
|
||||
self.user_name = user_name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
time_str = self.timestamp.strftime("%H:%M:%S")
|
||||
prefix = "[FINAL]" if self.is_final else "[PREVIEW]"
|
||||
if self.user_name and self.user_name.strip():
|
||||
return f"{prefix} [{time_str}] {self.user_name}: {self.text}"
|
||||
return f"{prefix} [{time_str}] {self.text}"
|
||||
# Re-export TranscriptionResult from the shared models module for backward compatibility
|
||||
from client.models import TranscriptionResult # noqa: F401
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
|
||||
@@ -42,7 +42,7 @@ transcription:
|
||||
|
||||
server_sync:
|
||||
enabled: false
|
||||
url: "http://localhost:3000/api/send"
|
||||
url: ""
|
||||
room: "default"
|
||||
passphrase: ""
|
||||
# Font settings are now in the display section (shared for local and server sync)
|
||||
@@ -69,7 +69,7 @@ web_server:
|
||||
host: "127.0.0.1"
|
||||
|
||||
remote:
|
||||
mode: local # local | managed | byok
|
||||
mode: byok # local | managed | byok
|
||||
server_url: "" # Proxy server URL for managed mode (e.g., wss://your-proxy.com)
|
||||
auth_token: "" # JWT stored after login (managed mode)
|
||||
byok_api_key: "" # Deepgram API key for BYOK mode
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# -*- 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'),
|
||||
]
|
||||
|
||||
# Collect sounddevice's bundled PortAudio library (_sounddevice_data)
|
||||
try:
|
||||
import sounddevice
|
||||
sd_path = os.path.dirname(sounddevice.__file__)
|
||||
sd_data = os.path.join(sd_path, '_sounddevice_data')
|
||||
if os.path.exists(sd_data):
|
||||
datas.append((sd_data, '_sounddevice_data'))
|
||||
print(f" + Collected sounddevice PortAudio data from {sd_data}")
|
||||
# Also collect the package itself
|
||||
sd_datas = collect_data_files('sounddevice')
|
||||
if sd_datas:
|
||||
datas += sd_datas
|
||||
print(f" + Collected {len(sd_datas)} sounddevice data files")
|
||||
except ImportError:
|
||||
print(" - Warning: sounddevice not found")
|
||||
|
||||
# Hidden imports -- only lightweight deps needed for Deepgram streaming
|
||||
hiddenimports = [
|
||||
'sounddevice',
|
||||
'_sounddevice_data',
|
||||
'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',
|
||||
)
|
||||
@@ -38,6 +38,21 @@ datas = [
|
||||
(vad_assets_path, 'faster_whisper/assets'),
|
||||
] + pvporcupine_data_files
|
||||
|
||||
# Collect sounddevice's bundled PortAudio library (_sounddevice_data)
|
||||
try:
|
||||
import sounddevice
|
||||
sd_path = os.path.dirname(sounddevice.__file__)
|
||||
sd_data = os.path.join(sd_path, '_sounddevice_data')
|
||||
if os.path.exists(sd_data):
|
||||
datas.append((sd_data, '_sounddevice_data'))
|
||||
print(f" + Collected sounddevice PortAudio data from {sd_data}")
|
||||
sd_datas = collect_data_files('sounddevice')
|
||||
if sd_datas:
|
||||
datas += sd_datas
|
||||
print(f" + Collected {len(sd_datas)} sounddevice data files")
|
||||
except ImportError:
|
||||
print(" - Warning: sounddevice not found")
|
||||
|
||||
# Hidden imports -- NO PySide6/Qt needed for headless backend
|
||||
hiddenimports = [
|
||||
# Transcription engine
|
||||
@@ -46,6 +61,7 @@ hiddenimports = [
|
||||
'faster_whisper.vad',
|
||||
'ctranslate2',
|
||||
'sounddevice',
|
||||
'_sounddevice_data',
|
||||
'scipy',
|
||||
'scipy.signal',
|
||||
'numpy',
|
||||
|
||||
Generated
+1082
-3
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "local-transcription",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "2.0.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -12,16 +12,19 @@
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"jsdom": "^29.0.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0"
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "local-transcription"
|
||||
version = "1.0.0"
|
||||
version = "1.0.11"
|
||||
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -703,6 +703,36 @@ app.post('/api/send', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Create room explicitly (no transcription needed)
|
||||
app.post('/api/create-room', async (req, res) => {
|
||||
try {
|
||||
const { room, passphrase } = req.body;
|
||||
|
||||
if (!room || !passphrase) {
|
||||
return res.status(400).json({ error: 'Missing room or passphrase' });
|
||||
}
|
||||
|
||||
// Check if room already exists
|
||||
const existing = await loadRoom(room);
|
||||
if (existing) {
|
||||
const valid = await verifyPassphrase(room, passphrase);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Room exists with different passphrase' });
|
||||
}
|
||||
return res.json({ status: 'ok', room, created: false, message: 'Room already exists' });
|
||||
}
|
||||
|
||||
// Create the room (verifyPassphrase creates it if it doesn't exist)
|
||||
await verifyPassphrase(room, passphrase);
|
||||
|
||||
console.log(`[Room] Created room "${room}"`);
|
||||
res.json({ status: 'ok', room, created: true });
|
||||
} catch (err) {
|
||||
console.error('Error in /api/create-room:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// List transcriptions
|
||||
app.get('/api/list', async (req, res) => {
|
||||
try {
|
||||
|
||||
Generated
+490
-8
@@ -47,6 +47,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -307,8 +316,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -338,6 +349,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -361,9 +382,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -374,7 +395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -515,6 +536,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -792,6 +824,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -799,7 +840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -813,6 +854,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -1222,6 +1269,25 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.1",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1332,6 +1398,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1342,6 +1409,38 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1360,9 +1459,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1766,6 +1867,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -1774,8 +1881,12 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "local-transcription"
|
||||
version = "1.4.0"
|
||||
version = "2.0.12"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
@@ -1783,6 +1894,9 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-shell",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1911,6 +2025,23 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -2132,6 +2263,50 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.76"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.112"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2727,6 +2902,49 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
@@ -2757,7 +2975,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"wasm-streams 0.5.0",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -2785,6 +3003,20 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
@@ -2800,12 +3032,64 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2815,6 +3099,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -2872,6 +3165,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
@@ -3014,6 +3330,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.18.0"
|
||||
@@ -3294,6 +3622,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -3347,6 +3681,27 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3368,7 +3723,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dispatch2",
|
||||
@@ -3445,7 +3800,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3719,6 +4074,19 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
@@ -3830,11 +4198,45 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4116,6 +4518,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4165,6 +4573,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -4323,6 +4737,19 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
@@ -4599,6 +5026,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4644,6 +5082,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -5132,6 +5579,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
@@ -5165,8 +5618,37 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.13.1",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "local-transcription"
|
||||
version = "1.4.0"
|
||||
version = "2.0.15"
|
||||
description = "Real-time speech-to-text transcription for streamers"
|
||||
authors = ["Local Transcription Contributors"]
|
||||
edition = "2021"
|
||||
@@ -19,3 +19,12 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-process = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
futures-util = "0.3"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
bytes = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
chrono = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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"]}}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 20 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 739 B |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 41 KiB |
+91
-2
@@ -1,9 +1,98 @@
|
||||
mod sidecar;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
/// App log directory, set during setup.
|
||||
static LOG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Write a log message to the app's log file (for debugging).
|
||||
#[tauri::command]
|
||||
fn write_log(message: String) {
|
||||
if let Some(log_dir) = LOG_DIR.get() {
|
||||
let log_path = log_dir.join("frontend.log");
|
||||
use std::io::Write;
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
{
|
||||
let _ = writeln!(f, "[{}] {}", chrono::Local::now().format("%H:%M:%S%.3f"), message);
|
||||
}
|
||||
}
|
||||
eprintln!("[frontend] {}", message);
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.manage(sidecar::ManagedSidecar(std::sync::Arc::new(Mutex::new(
|
||||
sidecar::SidecarManager::new(),
|
||||
))))
|
||||
.setup(|app| {
|
||||
let resource_dir = app
|
||||
.path()
|
||||
.resource_dir()
|
||||
.expect("failed to resolve resource dir");
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to resolve app data dir");
|
||||
|
||||
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
|
||||
|
||||
// Set up logging
|
||||
LOG_DIR.set(data_dir.clone()).ok();
|
||||
let log_path = data_dir.join("app.log");
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(f, "\n=== App started at {} ===", chrono::Local::now());
|
||||
let _ = writeln!(f, "Resource dir: {}", resource_dir.display());
|
||||
let _ = writeln!(f, "Data dir: {}", data_dir.display());
|
||||
}
|
||||
|
||||
sidecar::init_dirs(resource_dir, data_dir);
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
sidecar::check_sidecar,
|
||||
sidecar::download_sidecar,
|
||||
sidecar::check_sidecar_update,
|
||||
sidecar::get_sidecar_port,
|
||||
sidecar::start_sidecar,
|
||||
sidecar::stop_sidecar,
|
||||
sidecar::reset_sidecar,
|
||||
write_log,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
match event {
|
||||
tauri::RunEvent::Exit => {
|
||||
if let Some(state) = app.try_state::<sidecar::ManagedSidecar>() {
|
||||
if let Ok(mut mgr) = state.0.lock() {
|
||||
eprintln!("[app] Stopping sidecar on exit...");
|
||||
mgr.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
// Also stop sidecar on exit request (Cmd+Q on macOS)
|
||||
if let Some(state) = app.try_state::<sidecar::ManagedSidecar>() {
|
||||
if let Ok(mut mgr) = state.0.lock() {
|
||||
eprintln!("[app] Stopping sidecar on exit request...");
|
||||
mgr.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"productName": "Local Transcription",
|
||||
"version": "1.4.0",
|
||||
"identifier": "com.localtranscription.app",
|
||||
"version": "2.0.15",
|
||||
"identifier": "net.anhonesthost.local-transcription",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@@ -30,6 +30,7 @@
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.png"
|
||||
]
|
||||
|
||||
+225
-3
@@ -5,13 +5,22 @@
|
||||
import Controls from "$lib/components/Controls.svelte";
|
||||
import TranscriptionDisplay from "$lib/components/TranscriptionDisplay.svelte";
|
||||
import Settings from "$lib/components/Settings.svelte";
|
||||
import SidecarSetup from "$lib/components/SidecarSetup.svelte";
|
||||
import { backendStore } from "$lib/stores/backend";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
type SidecarState = "checking" | "needs_setup" | "update_available" | "starting" | "connected";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let sidecarState = $state<SidecarState>("checking");
|
||||
let debugLog = $state("");
|
||||
let availableUpdate = $state("");
|
||||
let appVersion = $state("");
|
||||
|
||||
let obsDisplayUrl = $derived(backendStore.obsUrl);
|
||||
let syncDisplayUrl = $derived(backendStore.syncUrl);
|
||||
let isConnected = $derived(backendStore.connectionState === "connected");
|
||||
let connectionState = $derived(backendStore.connectionState);
|
||||
|
||||
function openSettings() {
|
||||
showSettings = true;
|
||||
@@ -21,9 +30,94 @@
|
||||
showSettings = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let tauriInvoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[App] ${msg}`);
|
||||
debugLog = msg;
|
||||
// Also write to file via Tauri if available
|
||||
tauriInvoke?.("write_log", { message: msg });
|
||||
}
|
||||
|
||||
async function checkAndLaunchSidecar() {
|
||||
try {
|
||||
log("Importing Tauri API...");
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
tauriInvoke = invoke;
|
||||
|
||||
log("Checking if sidecar is installed...");
|
||||
sidecarState = "checking";
|
||||
const installed = await invoke<boolean>("check_sidecar");
|
||||
log(`Sidecar installed: ${installed}`);
|
||||
|
||||
if (!installed) {
|
||||
sidecarState = "needs_setup";
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for sidecar updates before launching
|
||||
try {
|
||||
log("Checking for sidecar updates...");
|
||||
const update = await invoke<string | null>("check_sidecar_update");
|
||||
if (update) {
|
||||
log(`Sidecar update available: ${update}`);
|
||||
availableUpdate = update;
|
||||
sidecarState = "update_available";
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Update check failed (non-fatal): ${err}`);
|
||||
}
|
||||
|
||||
await launchSidecar();
|
||||
} catch (err) {
|
||||
// Not running in Tauri (browser dev mode) - skip sidecar check
|
||||
// and connect directly to localhost:8081
|
||||
log(`Tauri not available (${err}), using dev mode`);
|
||||
sidecarState = "starting";
|
||||
backendStore.setPort(8081);
|
||||
backendStore.connect();
|
||||
configStore.loadConfig();
|
||||
configStore.fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
async function launchSidecar() {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
|
||||
log("Starting sidecar...");
|
||||
sidecarState = "starting";
|
||||
await invoke("start_sidecar");
|
||||
|
||||
log("Getting sidecar port...");
|
||||
const port = await invoke<number>("get_sidecar_port");
|
||||
log(`Sidecar ready on port ${port}`);
|
||||
backendStore.setPort(port);
|
||||
backendStore.connect();
|
||||
configStore.fetchConfig();
|
||||
} catch (err) {
|
||||
// If sidecar launch fails, still try connecting to default port
|
||||
log(`Sidecar launch failed: ${err}, trying default port`);
|
||||
sidecarState = "starting";
|
||||
backendStore.connect();
|
||||
configStore.fetchConfig();
|
||||
}
|
||||
}
|
||||
|
||||
async function onSidecarReady() {
|
||||
await launchSidecar();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Get app version from Tauri
|
||||
import("@tauri-apps/api/app").then(({ getVersion }) =>
|
||||
getVersion().then((v) => { appVersion = v; })
|
||||
).catch(() => {
|
||||
// Browser dev mode -- read from package.json or use fallback
|
||||
appVersion = "dev";
|
||||
});
|
||||
|
||||
checkAndLaunchSidecar();
|
||||
|
||||
return () => {
|
||||
backendStore.disconnect();
|
||||
@@ -31,6 +125,72 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if sidecarState === "checking"}
|
||||
<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-icon">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<h2 style="font-size:20px;margin:16px 0 8px;">Local Transcription</h2>
|
||||
<p style="color:#a0a0a0;font-size:14px;">Checking setup...</p>
|
||||
{#if debugLog}
|
||||
<p style="color:#707070;font-size:11px;margin-top:12px;">{debugLog}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if sidecarState === "needs_setup"}
|
||||
<SidecarSetup onComplete={onSidecarReady} />
|
||||
|
||||
{:else if sidecarState === "update_available"}
|
||||
<div class="connecting-overlay" style="background:#1e1e1e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100%;width:100%;">
|
||||
<div class="connecting-content" style="text-align:center;max-width:400px;">
|
||||
<h2 style="font-size:20px;margin:0 0 12px;">Sidecar Update Available</h2>
|
||||
<p style="color:#a0a0a0;font-size:14px;margin:0 0 20px;">
|
||||
A new version of the transcription engine is available ({availableUpdate}).
|
||||
</p>
|
||||
<div style="display:flex;gap:10px;justify-content:center;">
|
||||
<button
|
||||
style="padding:8px 20px;border:1px solid #555;border-radius:6px;background:transparent;color:#e0e0e0;cursor:pointer;"
|
||||
onclick={() => launchSidecar()}
|
||||
>Skip</button>
|
||||
<button
|
||||
style="padding:8px 20px;border:none;border-radius:6px;background:#4CAF50;color:white;cursor:pointer;font-weight:500;"
|
||||
onclick={() => { sidecarState = "needs_setup"; }}
|
||||
>Update Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if !isConnected}
|
||||
<div class="connecting-overlay" style="background:#1e1e1e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100%;width:100%;">
|
||||
<div class="connecting-content" style="text-align:center;">
|
||||
<div class="connecting-icon">
|
||||
{#if connectionState === "error"}
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#e74c3c" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<div class="spinner"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<h2 style="font-size:20px;margin:16px 0 8px;">Local Transcription</h2>
|
||||
{#if connectionState === "error"}
|
||||
<p style="color:#a0a0a0;">Cannot connect to backend</p>
|
||||
<p class="hint">Make sure the Python backend is running:<br>
|
||||
<code>uv run python -m backend.main_headless</code></p>
|
||||
{:else}
|
||||
<p style="color:#a0a0a0;">Connecting to backend...</p>
|
||||
{/if}
|
||||
{#if debugLog}
|
||||
<p style="color:#707070;font-size:11px;margin-top:12px;">{debugLog}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="app-shell">
|
||||
<Header onSettingsClick={openSettings} />
|
||||
<StatusBar />
|
||||
@@ -50,14 +210,76 @@
|
||||
<TranscriptionDisplay />
|
||||
<Controls />
|
||||
|
||||
<div class="version-label">v{backendStore.version}</div>
|
||||
<div class="version-label">v{appVersion || backendStore.version}</div>
|
||||
</div>
|
||||
|
||||
{#if showSettings}
|
||||
<Settings onClose={closeSettings} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.connecting-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.connecting-content {
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.connecting-content h2 {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connecting-content p {
|
||||
margin: 4px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.connecting-content .hint {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.connecting-content code {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.connecting-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border-color);
|
||||
border-top-color: var(--accent-color, #4CAF50);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { backendStore } from "$lib/stores/backend";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
import { transcriptionStore } from "$lib/stores/transcriptions";
|
||||
|
||||
let isTranscribing = $derived(backendStore.appState === "transcribing");
|
||||
let isReady = $derived(
|
||||
backendStore.appState === "ready" || backendStore.appState === "transcribing"
|
||||
);
|
||||
let isLoading = $state(false);
|
||||
|
||||
let remoteMode = $derived(configStore.config.remote.mode);
|
||||
let byokApiKey = $derived(configStore.config.remote.byok_api_key);
|
||||
let authToken = $derived(configStore.config.remote.auth_token);
|
||||
|
||||
let cloudConfigured = $derived(
|
||||
remoteMode === "local" ||
|
||||
(remoteMode === "byok" && byokApiKey.trim() !== "") ||
|
||||
(remoteMode === "managed" && authToken.trim() !== "")
|
||||
);
|
||||
|
||||
let errorMessage = $state("");
|
||||
|
||||
async function toggleTranscription() {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
errorMessage = "";
|
||||
try {
|
||||
if (isTranscribing) {
|
||||
await backendStore.apiPost("/api/stop");
|
||||
} else {
|
||||
await backendStore.apiPost("/api/start");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// Ignore "Already transcribing/not transcribing" -- just sync the state
|
||||
if (!msg.includes("400")) {
|
||||
console.error("Failed to toggle transcription:", msg);
|
||||
errorMessage = msg;
|
||||
}
|
||||
} finally {
|
||||
// Always poll status to sync UI with actual backend state,
|
||||
// even if the API call failed (e.g. "Already transcribing")
|
||||
await backendStore.pollStatus();
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearTranscriptions() {
|
||||
try {
|
||||
await backendStore.apiPost("/api/clear");
|
||||
transcriptionStore.clearAll();
|
||||
} catch (err) {
|
||||
console.error("Failed to clear:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTranscriptions() {
|
||||
try {
|
||||
// Get transcription text from backend or local store
|
||||
let text: string;
|
||||
try {
|
||||
const data = await backendStore.apiGet<{ text: string }>("/api/transcriptions");
|
||||
text = data.text || transcriptionStore.getPlainText();
|
||||
} catch {
|
||||
text = transcriptionStore.getPlainText();
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
console.warn("No transcriptions to save");
|
||||
return;
|
||||
}
|
||||
|
||||
// Try Tauri dialog for native save, fall back to browser download
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const filePath = await save({
|
||||
defaultPath: "transcription.txt",
|
||||
filters: [
|
||||
{ name: "Text Files", extensions: ["txt"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
});
|
||||
if (filePath) {
|
||||
// Write via backend API
|
||||
await backendStore.apiPost("/api/save-file", { path: filePath, text });
|
||||
}
|
||||
} catch {
|
||||
// Fallback: browser-style download
|
||||
const blob = new Blob([text], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "transcription.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to save:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
class={isTranscribing ? "danger" : "primary"}
|
||||
onclick={toggleTranscription}
|
||||
disabled={!isReady || isLoading || !cloudConfigured}
|
||||
>
|
||||
{#if isLoading}
|
||||
...
|
||||
{:else if isTranscribing}
|
||||
Stop Transcription
|
||||
{:else}
|
||||
Start Transcription
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button onclick={clearTranscriptions} disabled={!backendStore.connected}>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
<button onclick={saveTranscriptions} disabled={!backendStore.connected}>
|
||||
Save
|
||||
</button>
|
||||
|
||||
{#if errorMessage}
|
||||
<span class="error-msg">{errorMessage}</span>
|
||||
{/if}
|
||||
|
||||
{#if !cloudConfigured && isReady}
|
||||
<div class="cloud-warning">
|
||||
{#if remoteMode === "byok"}
|
||||
<span>API key required. Get one at
|
||||
<a href="https://console.deepgram.com" target="_blank" rel="noopener">console.deepgram.com</a>,
|
||||
then enter it in Settings.</span>
|
||||
{:else if remoteMode === "managed"}
|
||||
<span>Login required. Open Settings to log in.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-msg {
|
||||
color: #f44336;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.cloud-warning {
|
||||
font-size: 12px;
|
||||
color: #ff9800;
|
||||
margin-left: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cloud-warning a {
|
||||
color: #4fc3f7;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onSettingsClick: () => void;
|
||||
}
|
||||
|
||||
let { onSettingsClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="app-header">
|
||||
<h1 class="app-title">Local Transcription</h1>
|
||||
<button class="settings-btn" onclick={onSettingsClick} title="Settings">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1
|
||||
0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0
|
||||
0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2
|
||||
2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65
|
||||
1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2
|
||||
0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68
|
||||
15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0
|
||||
0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0
|
||||
0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1
|
||||
2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0
|
||||
0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2
|
||||
2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0
|
||||
1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0
|
||||
2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65
|
||||
0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0
|
||||
1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,420 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
let { onComplete }: Props = $props();
|
||||
|
||||
type SetupState = "choose" | "downloading" | "error" | "success";
|
||||
|
||||
let setupState = $state<SetupState>("choose");
|
||||
let variant = $state<"cpu" | "cuda">("cpu");
|
||||
let progress = $state(0);
|
||||
let progressMessage = $state("");
|
||||
let errorMessage = $state("");
|
||||
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
async function startDownload() {
|
||||
setupState = "downloading";
|
||||
progress = 0;
|
||||
progressMessage = "Starting download...";
|
||||
errorMessage = "";
|
||||
|
||||
try {
|
||||
// Listen for progress events from the Tauri backend
|
||||
unlisten = await listen<{ downloaded: number; total: number; phase: string; message: string }>(
|
||||
"sidecar-download-progress",
|
||||
(event) => {
|
||||
const { downloaded, total, message } = event.payload;
|
||||
progress = total > 0 ? (downloaded / total) * 100 : 0;
|
||||
progressMessage = message;
|
||||
}
|
||||
);
|
||||
|
||||
await invoke("download_sidecar", { variant });
|
||||
|
||||
// Download complete
|
||||
setupState = "success";
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
|
||||
// Brief pause to show success, then proceed
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setupState = "error";
|
||||
errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
setupState = "choose";
|
||||
progress = 0;
|
||||
progressMessage = "";
|
||||
errorMessage = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="setup-overlay">
|
||||
<div class="setup-card">
|
||||
<div class="setup-header">
|
||||
<h1 class="app-title">Local Transcription</h1>
|
||||
<h2 class="setup-heading">First-Time Setup</h2>
|
||||
</div>
|
||||
|
||||
{#if setupState === "choose"}
|
||||
<p class="setup-description">
|
||||
Choose a transcription engine. You can change this later in Settings.
|
||||
</p>
|
||||
|
||||
<div class="variant-options">
|
||||
<label class="variant-option" class:selected={variant === "cloud"}>
|
||||
<input
|
||||
type="radio"
|
||||
name="variant"
|
||||
value="cloud"
|
||||
bind:group={variant}
|
||||
/>
|
||||
<div class="variant-info">
|
||||
<span class="variant-name">Cloud (Deepgram)</span>
|
||||
<span class="variant-desc">~50 MB download</span>
|
||||
<span class="variant-detail">
|
||||
Fast, accurate streaming transcription via Deepgram's servers.
|
||||
Requires internet and a Deepgram API key.
|
||||
Best for most users — low resource usage, works on any hardware.
|
||||
</span>
|
||||
<span class="variant-tag recommended">Recommended</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="variant-option" class:selected={variant === "cpu"}>
|
||||
<input
|
||||
type="radio"
|
||||
name="variant"
|
||||
value="cpu"
|
||||
bind:group={variant}
|
||||
/>
|
||||
<div class="variant-info">
|
||||
<span class="variant-name">Local - CPU</span>
|
||||
<span class="variant-desc">~500 MB download</span>
|
||||
<span class="variant-detail">
|
||||
Runs Whisper AI models locally on your CPU. No internet needed
|
||||
after download. Good for privacy or offline use, but slower and
|
||||
uses more system resources than cloud.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
|
||||
<button class="download-btn" onclick={startDownload}>
|
||||
Download & Install
|
||||
</button>
|
||||
|
||||
{:else if setupState === "downloading"}
|
||||
<div class="progress-section">
|
||||
<p class="progress-message">{progressMessage}</p>
|
||||
<div class="progress-bar-track">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="progress-percent">{Math.round(progress)}%</p>
|
||||
</div>
|
||||
|
||||
{:else if setupState === "error"}
|
||||
<div class="error-section">
|
||||
<div class="error-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#f44336" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="error-title">Download Failed</p>
|
||||
<p class="error-message">{errorMessage}</p>
|
||||
<button class="retry-btn" onclick={retry}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if setupState === "success"}
|
||||
<div class="success-section">
|
||||
<div class="success-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#4CAF50" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="16 9 10.5 15 8 12.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="success-title">Setup Complete</p>
|
||||
<p class="success-message">The transcription engine is ready to go.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setup-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.setup-heading {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.setup-description {
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.variant-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.variant-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid #444;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.variant-option:hover {
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.variant-option.selected {
|
||||
border-color: #4CAF50;
|
||||
background-color: rgba(76, 175, 80, 0.08);
|
||||
}
|
||||
|
||||
.variant-option input[type="radio"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.variant-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.variant-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.variant-desc {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.variant-detail {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.variant-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
margin-top: 4px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.variant-tag.recommended {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background-color: #4CAF50;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.download-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Progress state */
|
||||
.progress-section {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.progress-message {
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #4CAF50;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: 13px;
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error-section {
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f44336;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: inline-block;
|
||||
padding: 10px 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background-color: #4CAF50;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
/* Success state */
|
||||
.success-section {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { backendStore } from "$lib/stores/backend";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let statusColor = $derived.by(() => {
|
||||
switch (backendStore.appState) {
|
||||
case "initializing":
|
||||
return "#ff9800";
|
||||
case "ready":
|
||||
return "#4caf50";
|
||||
case "transcribing":
|
||||
return "#f44336";
|
||||
case "error":
|
||||
return "#f44336";
|
||||
default:
|
||||
return "#888";
|
||||
}
|
||||
});
|
||||
|
||||
let isPulsing = $derived(backendStore.appState === "transcribing");
|
||||
let userName = $derived(configStore.config.user.name);
|
||||
</script>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-left">
|
||||
<span
|
||||
class="status-indicator"
|
||||
class:pulsing={isPulsing}
|
||||
style="background-color: {statusColor}"
|
||||
></span>
|
||||
<span class="state-message">{backendStore.stateMessage}</span>
|
||||
</div>
|
||||
<div class="status-right">
|
||||
{#if backendStore.deviceInfo}
|
||||
<span class="device-info">{backendStore.deviceInfo}</span>
|
||||
<span class="separator">|</span>
|
||||
{/if}
|
||||
<span class="user-name">{userName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 20px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator.pulsing {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 0 0 6px rgba(244, 67, 54, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.state-message {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-info {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--accent-green);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { transcriptionStore } from "$lib/stores/transcriptions";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let showTimestamps = $derived(configStore.config.display.show_timestamps);
|
||||
let items = $derived(transcriptionStore.items);
|
||||
|
||||
$effect(() => {
|
||||
// Trigger on items length change to auto-scroll
|
||||
const _len = items.length;
|
||||
if (container) {
|
||||
requestAnimationFrame(() => {
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="transcription-display" bind:this={container}>
|
||||
{#each items as item (item.id)}
|
||||
<div class="transcription-item" class:preview={item.isPreview}>
|
||||
{#if showTimestamps && item.timestamp}
|
||||
<span class="timestamp">[{item.timestamp}]</span>
|
||||
{/if}
|
||||
{#if item.userName}
|
||||
<span class="user-name">{item.userName}:</span>
|
||||
{/if}
|
||||
{#if item.isPreview}
|
||||
<span class="preview-indicator">[...]</span>
|
||||
{/if}
|
||||
<span class="text">{item.text}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
Transcriptions will appear here...
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.transcription-display {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.transcription-item {
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.transcription-item.preview {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
margin-right: 8px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: #4caf50;
|
||||
font-weight: 700;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.preview-indicator {
|
||||
color: #888;
|
||||
font-size: 0.85em;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Backend store - manages WebSocket connection and REST API communication
|
||||
* with the Python backend server running on localhost.
|
||||
*
|
||||
* The backend port defaults to 8081 but can be updated at runtime via
|
||||
* `setPort()`. The WebSocket connects to /ws/control for real-time push
|
||||
* of transcriptions, previews, and state changes.
|
||||
*/
|
||||
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected" | "error";
|
||||
export type AppState = "initializing" | "ready" | "transcribing" | "reloading" | "error";
|
||||
|
||||
interface BackendState {
|
||||
port: number;
|
||||
connectionState: ConnectionState;
|
||||
appState: AppState;
|
||||
stateMessage: string;
|
||||
deviceInfo: string;
|
||||
wsConnection: WebSocket | null;
|
||||
version: string;
|
||||
lastError: string;
|
||||
}
|
||||
|
||||
let state = $state<BackendState>({
|
||||
port: 8081,
|
||||
connectionState: "disconnected",
|
||||
appState: "initializing",
|
||||
stateMessage: "Connecting to backend...",
|
||||
deviceInfo: "",
|
||||
wsConnection: null,
|
||||
version: "1.4.0",
|
||||
lastError: "",
|
||||
});
|
||||
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_DELAY_MS = 30_000;
|
||||
const BASE_RECONNECT_DELAY_MS = 1_000;
|
||||
|
||||
// ── URL helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
const normalised = path.startsWith("/") ? path : `/${path}`;
|
||||
return `http://localhost:${state.port}${normalised}`;
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||
const url = apiUrl(path);
|
||||
const method = options?.method?.toUpperCase() ?? "GET";
|
||||
const headers = new Headers(options?.headers);
|
||||
if (method !== "GET" && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
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 ─────────────────────────────────────────────
|
||||
|
||||
function connectWebSocket() {
|
||||
// Tear down any existing connection
|
||||
disconnect();
|
||||
|
||||
state.connectionState = "connecting";
|
||||
reconnectAttempts = 0;
|
||||
|
||||
_openSocket();
|
||||
}
|
||||
|
||||
function _openSocket() {
|
||||
const wsUrl = `ws://localhost:${state.port}/ws/control`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
state.connectionState = "connected";
|
||||
state.lastError = "";
|
||||
reconnectAttempts = 0;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
// Poll status to catch engine ready state that may have been
|
||||
// missed (engine can finish before WebSocket connects)
|
||||
pollStatus();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWebSocketMessage(data);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
state.wsConnection = null;
|
||||
if (state.connectionState !== "disconnected") {
|
||||
state.connectionState = "error";
|
||||
state.stateMessage = "Disconnected from backend";
|
||||
_scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
state.lastError = "WebSocket error";
|
||||
// onclose fires after this, which handles reconnect
|
||||
};
|
||||
|
||||
state.wsConnection = ws;
|
||||
} catch {
|
||||
state.connectionState = "error";
|
||||
state.stateMessage = "Failed to connect";
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function _scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
|
||||
const delay = Math.min(
|
||||
BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts),
|
||||
MAX_RECONNECT_DELAY_MS,
|
||||
);
|
||||
reconnectAttempts++;
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (state.connectionState !== "disconnected") {
|
||||
state.connectionState = "connecting";
|
||||
_openSocket();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (statusPollTimer) {
|
||||
clearTimeout(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
state.connectionState = "disconnected";
|
||||
if (state.wsConnection) {
|
||||
const ws = state.wsConnection;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
state.wsConnection = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket message handling ───────────────────────────────────────
|
||||
|
||||
function handleWebSocketMessage(data: Record<string, unknown>) {
|
||||
// Handle state changes locally
|
||||
if (data.type === "state_changed") {
|
||||
if (data.state) {
|
||||
state.appState = data.state as AppState;
|
||||
}
|
||||
if (data.message) {
|
||||
state.stateMessage = data.message as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === "error") {
|
||||
state.lastError = (data.message as string) ?? "Unknown error";
|
||||
}
|
||||
|
||||
// Dispatch to window for other stores (transcriptions, etc.)
|
||||
if (data.type === "transcription") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("backend:transcription", { detail: data })
|
||||
);
|
||||
} else if (data.type === "preview") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("backend:preview", { detail: data })
|
||||
);
|
||||
} else if (data.type === "credits_low") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("backend:credits_low", { detail: data })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Port management ──────────────────────────────────────────────────
|
||||
|
||||
function setPort(newPort: number) {
|
||||
if (newPort === state.port) return;
|
||||
state.port = newPort;
|
||||
// Reconnect with new port if we had a connection
|
||||
if (state.connectionState !== "disconnected") {
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Typed REST helpers ───────────────────────────────────────────────
|
||||
|
||||
async function apiGet<T = unknown>(path: string): Promise<T> {
|
||||
const resp = await apiFetch(path);
|
||||
if (!resp.ok) throw new Error(`GET ${path} failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiPost<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const resp = await apiFetch(path, {
|
||||
method: "POST",
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`POST ${path} failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiPut<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const resp = await apiFetch(path, {
|
||||
method: "PUT",
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`PUT ${path} failed: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
export const backendStore = {
|
||||
get port() {
|
||||
return state.port;
|
||||
},
|
||||
get connectionState() {
|
||||
return state.connectionState;
|
||||
},
|
||||
get connected() {
|
||||
return state.connectionState === "connected";
|
||||
},
|
||||
get appState() {
|
||||
return state.appState;
|
||||
},
|
||||
get stateMessage() {
|
||||
return state.stateMessage;
|
||||
},
|
||||
get deviceInfo() {
|
||||
return state.deviceInfo;
|
||||
},
|
||||
get version() {
|
||||
return state.version;
|
||||
},
|
||||
get lastError() {
|
||||
return state.lastError;
|
||||
},
|
||||
get apiBaseUrl() {
|
||||
return `http://localhost:${state.port}`;
|
||||
},
|
||||
get wsUrl() {
|
||||
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,
|
||||
connect: connectWebSocket,
|
||||
disconnect,
|
||||
pollStatus,
|
||||
apiUrl,
|
||||
apiFetch,
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPut,
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Config store - manages application configuration loaded from
|
||||
* and saved to the Python backend via the backend store's API helpers.
|
||||
*
|
||||
* The backend accepts PUT /api/config with `{ settings: { "dot.key": value } }`.
|
||||
*/
|
||||
|
||||
import { backendStore } from "$lib/stores/backend";
|
||||
|
||||
export interface AppConfig {
|
||||
user: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
audio: {
|
||||
input_device: string;
|
||||
sample_rate: number;
|
||||
};
|
||||
transcription: {
|
||||
model: string;
|
||||
device: string;
|
||||
language: string;
|
||||
compute_type: string;
|
||||
enable_realtime_transcription: boolean;
|
||||
realtime_model: string;
|
||||
realtime_processing_pause: number;
|
||||
silero_sensitivity: number;
|
||||
silero_use_onnx: boolean;
|
||||
webrtc_sensitivity: number;
|
||||
post_speech_silence_duration: number;
|
||||
min_length_of_recording: number;
|
||||
min_gap_between_recordings: number;
|
||||
pre_recording_buffer_duration: number;
|
||||
beam_size: number;
|
||||
initial_prompt: string;
|
||||
no_log_file: boolean;
|
||||
continuous_mode: boolean;
|
||||
};
|
||||
server_sync: {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
room: string;
|
||||
passphrase: string;
|
||||
};
|
||||
display: {
|
||||
show_timestamps: boolean;
|
||||
max_lines: number;
|
||||
font_source: string;
|
||||
font_family: string;
|
||||
websafe_font: string;
|
||||
google_font: string;
|
||||
custom_font_file: string;
|
||||
font_size: number;
|
||||
theme: string;
|
||||
fade_after_seconds: number;
|
||||
user_color: string;
|
||||
text_color: string;
|
||||
background_color: string;
|
||||
};
|
||||
web_server: {
|
||||
port: number;
|
||||
host: string;
|
||||
};
|
||||
remote: {
|
||||
mode: string;
|
||||
server_url: string;
|
||||
auth_token: string;
|
||||
byok_api_key: string;
|
||||
deepgram_model: string;
|
||||
language: string;
|
||||
fallback_to_local: boolean;
|
||||
};
|
||||
updates: {
|
||||
auto_check: boolean;
|
||||
gitea_url: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
skipped_versions: string[];
|
||||
last_check: string;
|
||||
check_interval_hours: number;
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultConfig(): AppConfig {
|
||||
return {
|
||||
user: { name: "User", id: "" },
|
||||
audio: { input_device: "default", sample_rate: 16000 },
|
||||
transcription: {
|
||||
model: "base.en",
|
||||
device: "auto",
|
||||
language: "en",
|
||||
compute_type: "default",
|
||||
enable_realtime_transcription: false,
|
||||
realtime_model: "tiny.en",
|
||||
realtime_processing_pause: 0.1,
|
||||
silero_sensitivity: 0.4,
|
||||
silero_use_onnx: true,
|
||||
webrtc_sensitivity: 3,
|
||||
post_speech_silence_duration: 0.3,
|
||||
min_length_of_recording: 0.5,
|
||||
min_gap_between_recordings: 0,
|
||||
pre_recording_buffer_duration: 0.2,
|
||||
beam_size: 5,
|
||||
initial_prompt: "",
|
||||
no_log_file: true,
|
||||
continuous_mode: false,
|
||||
},
|
||||
server_sync: {
|
||||
enabled: false,
|
||||
url: "",
|
||||
room: "default",
|
||||
passphrase: "",
|
||||
},
|
||||
display: {
|
||||
show_timestamps: true,
|
||||
max_lines: 100,
|
||||
font_source: "System Font",
|
||||
font_family: "Courier",
|
||||
websafe_font: "Arial",
|
||||
google_font: "Roboto",
|
||||
custom_font_file: "",
|
||||
font_size: 12,
|
||||
theme: "dark",
|
||||
fade_after_seconds: 10,
|
||||
user_color: "#4CAF50",
|
||||
text_color: "#FFFFFF",
|
||||
background_color: "#000000B3",
|
||||
},
|
||||
web_server: { port: 8080, host: "127.0.0.1" },
|
||||
remote: {
|
||||
mode: "byok",
|
||||
server_url: "",
|
||||
auth_token: "",
|
||||
byok_api_key: "",
|
||||
deepgram_model: "nova-2",
|
||||
language: "en-US",
|
||||
fallback_to_local: true,
|
||||
},
|
||||
updates: {
|
||||
auto_check: true,
|
||||
gitea_url: "https://repo.anhonesthost.net",
|
||||
owner: "streamer-tools",
|
||||
repo: "local-transcription",
|
||||
skipped_versions: [],
|
||||
last_check: "",
|
||||
check_interval_hours: 24,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let config = $state<AppConfig>(getDefaultConfig());
|
||||
let loading = $state(false);
|
||||
let error = $state("");
|
||||
|
||||
/**
|
||||
* Fetch the full configuration tree from the backend.
|
||||
* GET /api/config
|
||||
*/
|
||||
async function fetchConfig(): Promise<void> {
|
||||
loading = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
const data = await backendStore.apiGet<Record<string, unknown>>("/api/config");
|
||||
// Deep merge with defaults to ensure all keys exist
|
||||
config = deepMerge(getDefaultConfig(), data) as AppConfig;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[config] fetchConfig failed:", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source)) {
|
||||
if (
|
||||
source[key] &&
|
||||
typeof source[key] === "object" &&
|
||||
!Array.isArray(source[key]) &&
|
||||
target[key] &&
|
||||
typeof target[key] === "object" &&
|
||||
!Array.isArray(target[key])
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
target[key] as Record<string, unknown>,
|
||||
source[key] as Record<string, unknown>
|
||||
);
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of setting updates to the backend.
|
||||
* PUT /api/config with body `{ settings: { "dot.key": value, ... } }`
|
||||
*
|
||||
* Keys use dot-notation, e.g. `{ "transcription.model": "small.en" }`.
|
||||
*
|
||||
* Returns the response payload on success, or throws on failure.
|
||||
*/
|
||||
async function updateConfig(
|
||||
settings: Record<string, unknown>,
|
||||
): Promise<{ status: string; message: string; engine_reloaded: boolean }> {
|
||||
loading = true;
|
||||
error = "";
|
||||
|
||||
try {
|
||||
const result = await backendStore.apiPut<{
|
||||
status: string;
|
||||
message: string;
|
||||
engine_reloaded: boolean;
|
||||
}>("/api/config", { settings });
|
||||
|
||||
// Refresh the local config tree so the UI stays in sync
|
||||
await fetchConfig();
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
console.error("[config] updateConfig failed:", error);
|
||||
throw err;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const configStore = {
|
||||
get config() {
|
||||
return config;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
fetchConfig,
|
||||
updateConfig,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Transcriptions store - manages the list of transcription items
|
||||
* received from the backend via WebSocket.
|
||||
*/
|
||||
|
||||
export interface TranscriptionItem {
|
||||
id: string;
|
||||
text: string;
|
||||
userName: string;
|
||||
timestamp: string;
|
||||
isPreview: boolean;
|
||||
}
|
||||
|
||||
let items = $state<TranscriptionItem[]>([]);
|
||||
let nextId = 0;
|
||||
|
||||
function generateId(): string {
|
||||
return `t-${Date.now()}-${nextId++}`;
|
||||
}
|
||||
|
||||
function addTranscription(data: {
|
||||
text?: string;
|
||||
user_name?: string;
|
||||
timestamp?: string;
|
||||
}) {
|
||||
// When a final transcription arrives, remove any existing preview
|
||||
const previewIndex = items.findIndex((item) => item.isPreview);
|
||||
if (previewIndex !== -1) {
|
||||
items.splice(previewIndex, 1);
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: generateId(),
|
||||
text: data.text ?? "",
|
||||
userName: data.user_name ?? "",
|
||||
timestamp: data.timestamp ?? "",
|
||||
isPreview: false,
|
||||
});
|
||||
|
||||
// Keep a reasonable limit
|
||||
if (items.length > 500) {
|
||||
items.splice(0, items.length - 500);
|
||||
}
|
||||
}
|
||||
|
||||
function setPreview(data: {
|
||||
text?: string;
|
||||
user_name?: string;
|
||||
timestamp?: string;
|
||||
}) {
|
||||
const existingIndex = items.findIndex((item) => item.isPreview);
|
||||
const previewItem: TranscriptionItem = {
|
||||
id: existingIndex !== -1 ? items[existingIndex].id : generateId(),
|
||||
text: data.text ?? "",
|
||||
userName: data.user_name ?? "",
|
||||
timestamp: data.timestamp ?? "",
|
||||
isPreview: true,
|
||||
};
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
items[existingIndex] = previewItem;
|
||||
} else {
|
||||
items.push(previewItem);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
items.length = 0;
|
||||
}
|
||||
|
||||
function getPlainText(): string {
|
||||
return items
|
||||
.filter((item) => !item.isPreview)
|
||||
.map((item) => {
|
||||
let line = "";
|
||||
if (item.timestamp) line += `[${item.timestamp}] `;
|
||||
if (item.userName) line += `${item.userName}: `;
|
||||
line += item.text;
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Listen for backend events
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("backend:transcription", ((e: CustomEvent) => {
|
||||
addTranscription(e.detail);
|
||||
}) as EventListener);
|
||||
|
||||
window.addEventListener("backend:preview", ((e: CustomEvent) => {
|
||||
setPreview(e.detail);
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
export const transcriptionStore = {
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
get currentPreview(): TranscriptionItem | null {
|
||||
return items.find((item) => item.isPreview) ?? null;
|
||||
},
|
||||
get transcriptions(): TranscriptionItem[] {
|
||||
return items.filter((item) => !item.isPreview);
|
||||
},
|
||||
addTranscription,
|
||||
setPreview,
|
||||
clearAll,
|
||||
getPlainText,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"""Version information for Local Transcription."""
|
||||
|
||||
__version__ = "1.4.0"
|
||||
__version_info__ = (1, 4, 0)
|
||||
__version__ = "2.0.15"
|
||||
__version_info__ = (2, 0, 15)
|
||||
|
||||
# Version history:
|
||||
# 1.4.0 - Auto-update feature:
|
||||
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
$lib: path.resolve("./src/lib"),
|
||||
},
|
||||
extensions: [".svelte.ts", ".ts", ".svelte", ".js", ".mjs", ".mts"],
|
||||
},
|
||||
server: {
|
||||
port: 1420,
|
||||
@@ -18,4 +19,8 @@ export default defineConfig({
|
||||
ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"],
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user