diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7e61cf9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - 'fix/**' + - 'feat/**' + workflow_dispatch: + +jobs: + test: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + cache: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install macOS dependencies + run: brew install portaudio + + - name: Install frontend dependencies + run: npm ci + working-directory: frontend + + - name: Build frontend + run: npm run build + working-directory: frontend + + - name: Test Go packages + run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f355c78..530efd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,6 @@ env: WAILS_VERSION: 'latest' jobs: - # Extract version from tag or input prepare: runs-on: ubuntu-latest outputs: @@ -38,8 +37,6 @@ jobs: echo "tag=${TAG}" >> $GITHUB_OUTPUT echo "Building version: ${VERSION}" - # Build for macOS (Apple Silicon) - # Note: Intel Macs can run via Rosetta 2 build-macos: needs: prepare runs-on: macos-latest @@ -62,7 +59,7 @@ jobs: - name: Install macOS dependencies run: | - brew install portaudio create-dmg + brew install portaudio whisper-cpp libomp create-dmg - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -73,23 +70,83 @@ jobs: - name: Build macOS App run: | - wails build -platform darwin/arm64 -o "Yap" + wails build -platform darwin/arm64 -o "Yap" -ldflags "-X main.appVersion=${{ needs.prepare.outputs.version }}" - - name: Bundle PortAudio in app + - name: Bundle macOS runtime libraries run: | - # Create Frameworks directory in app bundle - mkdir -p build/bin/Yap.app/Contents/Frameworks - - # Copy PortAudio dylib - cp /opt/homebrew/lib/libportaudio.2.dylib build/bin/Yap.app/Contents/Frameworks/ - - # Fix library path in binary to look inside the app bundle + set -euo pipefail + APP="build/bin/Yap.app" + mkdir -p "$APP/Contents/Frameworks" "$APP/Contents/Resources/bin" "$APP/Contents/Resources/lib" "$APP/Contents/Resources/libexec" + + cp /opt/homebrew/lib/libportaudio.2.dylib "$APP/Contents/Frameworks/" install_name_tool -change "/opt/homebrew/opt/portaudio/lib/libportaudio.2.dylib" \ "@executable_path/../Frameworks/libportaudio.2.dylib" \ - build/bin/Yap.app/Contents/MacOS/Yap - - # Re-sign the app (ad-hoc) since install_name_tool invalidates the signature - codesign --force --deep --sign - build/bin/Yap.app + "$APP/Contents/MacOS/Yap" + + cp /opt/homebrew/bin/whisper-cli /opt/homebrew/bin/whisper-server "$APP/Contents/Resources/bin/" + chmod +x "$APP/Contents/Resources/bin/whisper-cli" "$APP/Contents/Resources/bin/whisper-server" + + cp -L /opt/homebrew/opt/whisper-cpp/lib/libwhisper*.dylib "$APP/Contents/Resources/lib/" + cp -L /opt/homebrew/opt/ggml/lib/libggml*.dylib "$APP/Contents/Resources/lib/" + cp -L /opt/homebrew/opt/libomp/lib/libomp.dylib "$APP/Contents/Resources/lib/" + cp -L /opt/homebrew/opt/ggml/libexec/* "$APP/Contents/Resources/libexec/" || true + + for lib in "$APP/Contents/Resources/lib"/*.dylib; do + install_name_tool -id "@executable_path/../lib/$(basename "$lib")" "$lib" || true + done + + for bin in "$APP/Contents/Resources/bin"/* "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do + [ -f "$bin" ] || continue + otool -L "$bin" | awk '/\/opt\/homebrew\/(opt|Cellar)\/(whisper-cpp|ggml|libomp)\// {print $1}' | while read -r dep; do + base="$(basename "$dep")" + install_name_tool -change "$dep" "@loader_path/../lib/$base" "$bin" || true + done + otool -L "$bin" | awk '/@rpath\/lib(whisper|ggml|omp)/ {print $1}' | while read -r dep; do + base="$(basename "$dep")" + install_name_tool -change "$dep" "@loader_path/../lib/$base" "$bin" || true + done + done + + echo "Inspecting bundled whisper runtime dependencies" + for bin in "$APP/Contents/Resources/bin"/* "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/*; do + [ -f "$bin" ] || continue + otool -L "$bin" + if otool -L "$bin" | grep -E '/opt/homebrew/|/usr/local/|/Users/runner/work/|@rpath/' >/dev/null; then + echo "Unresolved non-system dependency in $bin" + exit 1 + fi + done + + CPU_BRAND="$(sysctl -n machdep.cpu.brand_string || true)" + case "$CPU_BRAND" in + *M4*) CPU_BACKEND_NAME="libggml-cpu-apple_m4.so" ;; + *M2*|*M3*) CPU_BACKEND_NAME="libggml-cpu-apple_m2_m3.so" ;; + *) CPU_BACKEND_NAME="libggml-cpu-apple_m1.so" ;; + esac + CPU_BACKEND="$APP/Contents/Resources/libexec/$CPU_BACKEND_NAME" + if [ ! -f "$CPU_BACKEND" ]; then + CPU_BACKEND="$APP/Contents/Resources/libexec/libggml-cpu-apple_m1.so" + fi + + echo "Smoke testing bundled whisper-cli" + "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help.txt + GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-with-backend.txt + GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-server" --help >/tmp/yap-whisper-server-help.txt + + echo "Signing bundled macOS runtime files" + for bin in "$APP/Contents/Resources/bin"/* "$APP/Contents/Resources/lib"/*.dylib "$APP/Contents/Resources/libexec"/* "$APP/Contents/Frameworks"/*.dylib; do + [ -f "$bin" ] || continue + codesign --force --sign - "$bin" + codesign --verify --strict --verbose=2 "$bin" + done + + codesign --force --sign - "$APP/Contents/MacOS/Yap" + codesign --verify --strict --verbose=2 "$APP/Contents/MacOS/Yap" + codesign --force --deep --sign - "$APP" + codesign --verify --deep --strict --verbose=2 "$APP" + GGML_BACKEND_PATH="$CPU_BACKEND" "$APP/Contents/Resources/bin/whisper-cli" --help >/tmp/yap-whisper-help-after-sign.txt + + echo "Bundled runtime dependency audit complete" - name: Create DMG run: | @@ -104,12 +161,20 @@ jobs: --app-drop-link 450 150 \ "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg" \ "build/bin/Yap.app" || true - - # Fallback to hdiutil if create-dmg fails + if [ ! -f "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg" ]; then hdiutil create -volname "Yap" -srcfolder "build/bin/Yap.app" -ov -format UDZO "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg" fi + MOUNT_OUTPUT=$(hdiutil attach -readonly -nobrowse "Yap-${{ needs.prepare.outputs.version }}-macOS.dmg") + MOUNT_DIR=$(printf '%s\n' "$MOUNT_OUTPUT" | awk '/\/Volumes\// {print substr($0, index($0, "/Volumes/")); exit}') + trap 'hdiutil detach "$MOUNT_DIR" >/dev/null 2>&1 || true' EXIT + test -d "$MOUNT_DIR/Yap.app" + test -L "$MOUNT_DIR/Applications" + codesign --verify --deep --strict --verbose=2 "$MOUNT_DIR/Yap.app" + hdiutil detach "$MOUNT_DIR" + trap - EXIT + - name: Upload macOS DMG uses: actions/upload-artifact@v4 with: @@ -117,7 +182,6 @@ jobs: path: Yap-${{ needs.prepare.outputs.version }}-macOS.dmg retention-days: 5 - # Build for Windows build-windows: needs: prepare runs-on: windows-latest @@ -138,21 +202,19 @@ jobs: cache: 'npm' cache-dependency-path: frontend/package-lock.json - - name: Install portaudio via vcpkg + - name: Install native dependencies via vcpkg run: | git clone https://github.com/Microsoft/vcpkg.git C:\vcpkg C:\vcpkg\bootstrap-vcpkg.bat - C:\vcpkg\vcpkg install portaudio:x64-windows + C:\vcpkg\vcpkg install portaudio:x64-windows whisper-cpp:x64-windows echo "CGO_CFLAGS=-IC:/vcpkg/installed/x64-windows/include" >> $env:GITHUB_ENV echo "CGO_LDFLAGS=-LC:/vcpkg/installed/x64-windows/lib -lportaudio" >> $env:GITHUB_ENV echo "PKG_CONFIG_PATH=C:/vcpkg/installed/x64-windows/lib/pkgconfig" >> $env:GITHUB_ENV - # Add DLL directory to PATH so portaudio.dll can be found at runtime echo "C:/vcpkg/installed/x64-windows/bin" >> $env:GITHUB_PATH - name: Install NSIS run: | choco install nsis -y - # Add NSIS to PATH echo "C:\Program Files (x86)\NSIS" >> $env:GITHUB_PATH - name: Install Wails @@ -162,21 +224,37 @@ jobs: run: npm install working-directory: frontend - - name: Copy PortAudio DLL to build directory + - name: Copy Windows runtime dependencies run: | - New-Item -ItemType Directory -Force -Path "build\bin" + New-Item -ItemType Directory -Force -Path "build\bin\bin" Copy-Item "C:\vcpkg\installed\x64-windows\bin\portaudio.dll" -Destination "build\bin\" + Copy-Item "C:\vcpkg\installed\x64-windows\tools\whisper-cpp\whisper-cli.exe" -Destination "build\bin\bin\" -ErrorAction SilentlyContinue + Copy-Item "C:\vcpkg\installed\x64-windows\tools\whisper-cpp\whisper-server.exe" -Destination "build\bin\bin\" -ErrorAction SilentlyContinue + if (-not (Test-Path "build\bin\bin\whisper-cli.exe")) { + Get-ChildItem -Path "C:\vcpkg\installed\x64-windows" -Filter "whisper-cli.exe" -Recurse | Select-Object -First 1 | Copy-Item -Destination "build\bin\bin\whisper-cli.exe" + } + if (-not (Test-Path "build\bin\bin\whisper-server.exe")) { + Get-ChildItem -Path "C:\vcpkg\installed\x64-windows" -Filter "whisper-server.exe" -Recurse | Select-Object -First 1 | Copy-Item -Destination "build\bin\bin\whisper-server.exe" + } + Get-ChildItem -Path "C:\vcpkg\installed\x64-windows\bin" -Filter "*.dll" | Where-Object { $_.Name -match "whisper|ggml|omp|openblas|blas" } | Copy-Item -Destination "build\bin\bin\" -ErrorAction SilentlyContinue - name: Build Windows App run: | - wails build -platform windows/amd64 -nsis -o "Yap.exe" + wails build -platform windows/amd64 -nsis -o "Yap.exe" -ldflags "-X main.appVersion=${{ needs.prepare.outputs.version }}" env: CGO_ENABLED: 1 - - name: List build output + - name: Ensure runtime files are next to Windows app run: | - echo "Contents of build/bin:" - Get-ChildItem -Path "build\bin" -Recurse | ForEach-Object { Write-Host $_.FullName } + New-Item -ItemType Directory -Force -Path "build\bin\bin" + if (Test-Path "build\bin\Yap") { + Copy-Item "build\bin\bin" -Destination "build\bin\Yap\bin" -Recurse -Force + } + New-Item -ItemType Directory -Force -Path "portable" + Copy-Item "build\bin\Yap.exe" -Destination "portable\" + Copy-Item "build\bin\portaudio.dll" -Destination "portable\" -ErrorAction SilentlyContinue + Copy-Item "build\bin\bin" -Destination "portable\bin" -Recurse -Force + Compress-Archive -Path "portable\*" -DestinationPath "Yap-${{ needs.prepare.outputs.version }}-Windows-Portable.zip" -Force - name: Rename installer run: | @@ -198,10 +276,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: windows-portable - path: build/bin/Yap.exe + path: Yap-${{ needs.prepare.outputs.version }}-Windows-Portable.zip retention-days: 5 - # Build for Linux (Ubuntu 22.04 - WebKitGTK 4.0) build-linux-ubuntu22: needs: prepare runs-on: ubuntu-22.04 @@ -225,13 +302,13 @@ jobs: - name: Install Linux dependencies run: | sudo apt-get update - sudo apt-get install -y \ - libgtk-3-dev \ - libwebkit2gtk-4.0-dev \ - portaudio19-dev \ - pkg-config \ - libfuse2 \ - fuse + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev portaudio19-dev pkg-config libfuse2 fuse cmake git build-essential patchelf + + - name: Build whisper.cpp CLI + run: | + git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git /tmp/whisper.cpp + cmake -S /tmp/whisper.cpp -B /tmp/whisper.cpp/build -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON + cmake --build /tmp/whisper.cpp/build --config Release --target whisper-cli whisper-server -j2 - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -242,28 +319,21 @@ jobs: - name: Build Linux App run: | - wails build -platform linux/amd64 -o "yap" + wails build -platform linux/amd64 -o "yap" -ldflags "-X main.appVersion=${{ needs.prepare.outputs.version }}" - name: Create AppImage run: | - # Download appimagetool wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x appimagetool-x86_64.AppImage - - # Create AppDir structure - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/share/applications - mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps - - # Copy binary + mkdir -p AppDir/usr/bin AppDir/usr/lib AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps cp build/bin/yap AppDir/usr/bin/ - chmod +x AppDir/usr/bin/yap - - # Copy PortAudio library for self-contained AppImage - mkdir -p AppDir/usr/lib + cp /tmp/whisper.cpp/build/bin/whisper-cli AppDir/usr/bin/ + cp /tmp/whisper.cpp/build/bin/whisper-server AppDir/usr/bin/ + chmod +x AppDir/usr/bin/yap AppDir/usr/bin/whisper-cli AppDir/usr/bin/whisper-server cp /usr/lib/x86_64-linux-gnu/libportaudio.so.2 AppDir/usr/lib/ - - # Create desktop entry + for bin in AppDir/usr/bin/whisper-cli AppDir/usr/bin/whisper-server; do + ldd "$bin" | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} AppDir/usr/lib/ + done cat > AppDir/usr/share/applications/yap.desktop << EOF [Desktop Entry] Name=Yap @@ -273,11 +343,7 @@ jobs: Type=Application Categories=Utility;Audio; EOF - - # Copy icon cp build/appicon.iconset/icon_256x256.png AppDir/usr/share/icons/hicolor/256x256/apps/yap.png - - # Create AppRun cat > AppDir/AppRun << 'EOF' #!/bin/bash SELF=$(readlink -f "$0") @@ -287,33 +353,27 @@ jobs: exec "${HERE}/usr/bin/yap" "$@" EOF chmod +x AppDir/AppRun - - # Link desktop and icon to root ln -sf usr/share/applications/yap.desktop AppDir/yap.desktop ln -sf usr/share/icons/hicolor/256x256/apps/yap.png AppDir/yap.png - - # Build AppImage ARCH=x86_64 ./appimagetool-x86_64.AppImage AppDir "Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu22.AppImage" - name: Create DEB package run: | VERSION="${{ needs.prepare.outputs.version }}" PKG_NAME="yap_${VERSION}_amd64" - - # Create package structure - mkdir -p "${PKG_NAME}/DEBIAN" - mkdir -p "${PKG_NAME}/usr/bin" - mkdir -p "${PKG_NAME}/usr/share/applications" - mkdir -p "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" - - # Copy binary + mkdir -p "${PKG_NAME}/DEBIAN" "${PKG_NAME}/usr/bin" "${PKG_NAME}/usr/lib/yap" "${PKG_NAME}/usr/share/applications" "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" cp build/bin/yap "${PKG_NAME}/usr/bin/" - chmod +x "${PKG_NAME}/usr/bin/yap" - - # Copy icon + cp /tmp/whisper.cpp/build/bin/whisper-cli "${PKG_NAME}/usr/bin/" + cp /tmp/whisper.cpp/build/bin/whisper-server "${PKG_NAME}/usr/bin/" + chmod +x "${PKG_NAME}/usr/bin/yap" "${PKG_NAME}/usr/bin/whisper-cli" "${PKG_NAME}/usr/bin/whisper-server" + for bin in "${PKG_NAME}/usr/bin/whisper-cli" "${PKG_NAME}/usr/bin/whisper-server"; do + ldd "$bin" | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} "${PKG_NAME}/usr/lib/yap/" + patchelf --set-rpath '$ORIGIN/../lib/yap' "$bin" + done + for lib in "${PKG_NAME}/usr/lib/yap"/*; do + [ -f "$lib" ] && patchelf --set-rpath '$ORIGIN' "$lib" || true + done cp build/appicon.iconset/icon_256x256.png "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps/yap.png" - - # Create desktop entry cat > "${PKG_NAME}/usr/share/applications/yap.desktop" << EOF [Desktop Entry] Name=Yap @@ -323,22 +383,17 @@ jobs: Type=Application Categories=Utility;Audio; EOF - - # Create control file cat > "${PKG_NAME}/DEBIAN/control" << EOF Package: yap Version: ${VERSION} Section: utils Priority: optional Architecture: amd64 - Depends: libgtk-3-0, libwebkit2gtk-4.0-37, libportaudio2 + Depends: libgtk-3-0, libwebkit2gtk-4.0-37, libportaudio2, libgomp1 Maintainer: Applause Lab Description: Speech-to-Text Desktop App - Yap is a speech-to-text desktop application - powered by OpenAI Whisper API. + Yap is a speech-to-text desktop application powered by Whisper. EOF - - # Build deb package dpkg-deb --build "${PKG_NAME}" mv "${PKG_NAME}.deb" "Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu22.deb" @@ -356,7 +411,6 @@ jobs: path: Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu22.deb retention-days: 5 - # Build for Linux (Ubuntu 24.04+ - WebKitGTK 4.1) build-linux-ubuntu24: needs: prepare runs-on: ubuntu-24.04 @@ -380,12 +434,13 @@ jobs: - name: Install Linux dependencies run: | sudo apt-get update - sudo apt-get install -y \ - libgtk-3-dev \ - libwebkit2gtk-4.1-dev \ - portaudio19-dev \ - pkg-config \ - libfuse2 + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev portaudio19-dev pkg-config libfuse2 cmake git build-essential patchelf + + - name: Build whisper.cpp CLI + run: | + git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git /tmp/whisper.cpp + cmake -S /tmp/whisper.cpp -B /tmp/whisper.cpp/build -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON + cmake --build /tmp/whisper.cpp/build --config Release --target whisper-cli whisper-server -j2 - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@${{ env.WAILS_VERSION }} @@ -396,29 +451,22 @@ jobs: - name: Build Linux App run: | - wails build -platform linux/amd64 -o "yap" -tags webkit2_41 + wails build -platform linux/amd64 -o "yap" -tags webkit2_41 -ldflags "-X main.appVersion=${{ needs.prepare.outputs.version }}" - name: Create AppImage run: | - # Download appimagetool and extract it (to avoid FUSE dependency) wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage chmod +x appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract - - # Create AppDir structure - mkdir -p AppDir/usr/bin - mkdir -p AppDir/usr/share/applications - mkdir -p AppDir/usr/share/icons/hicolor/256x256/apps - - # Copy binary + mkdir -p AppDir/usr/bin AppDir/usr/lib AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps cp build/bin/yap AppDir/usr/bin/ - chmod +x AppDir/usr/bin/yap - - # Copy PortAudio library for self-contained AppImage - mkdir -p AppDir/usr/lib + cp /tmp/whisper.cpp/build/bin/whisper-cli AppDir/usr/bin/ + cp /tmp/whisper.cpp/build/bin/whisper-server AppDir/usr/bin/ + chmod +x AppDir/usr/bin/yap AppDir/usr/bin/whisper-cli AppDir/usr/bin/whisper-server cp /usr/lib/x86_64-linux-gnu/libportaudio.so.2 AppDir/usr/lib/ - - # Create desktop entry + for bin in AppDir/usr/bin/whisper-cli AppDir/usr/bin/whisper-server; do + ldd "$bin" | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} AppDir/usr/lib/ + done cat > AppDir/usr/share/applications/yap.desktop << EOF [Desktop Entry] Name=Yap @@ -428,11 +476,7 @@ jobs: Type=Application Categories=Utility;Audio; EOF - - # Copy icon cp build/appicon.iconset/icon_256x256.png AppDir/usr/share/icons/hicolor/256x256/apps/yap.png - - # Create AppRun cat > AppDir/AppRun << 'EOF' #!/bin/bash SELF=$(readlink -f "$0") @@ -442,33 +486,27 @@ jobs: exec "${HERE}/usr/bin/yap" "$@" EOF chmod +x AppDir/AppRun - - # Link desktop and icon to root ln -sf usr/share/applications/yap.desktop AppDir/yap.desktop ln -sf usr/share/icons/hicolor/256x256/apps/yap.png AppDir/yap.png - - # Build AppImage using extracted appimagetool ARCH=x86_64 ./squashfs-root/AppRun AppDir "Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu24.AppImage" - name: Create DEB package run: | VERSION="${{ needs.prepare.outputs.version }}" PKG_NAME="yap_${VERSION}_amd64" - - # Create package structure - mkdir -p "${PKG_NAME}/DEBIAN" - mkdir -p "${PKG_NAME}/usr/bin" - mkdir -p "${PKG_NAME}/usr/share/applications" - mkdir -p "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" - - # Copy binary + mkdir -p "${PKG_NAME}/DEBIAN" "${PKG_NAME}/usr/bin" "${PKG_NAME}/usr/lib/yap" "${PKG_NAME}/usr/share/applications" "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps" cp build/bin/yap "${PKG_NAME}/usr/bin/" - chmod +x "${PKG_NAME}/usr/bin/yap" - - # Copy icon + cp /tmp/whisper.cpp/build/bin/whisper-cli "${PKG_NAME}/usr/bin/" + cp /tmp/whisper.cpp/build/bin/whisper-server "${PKG_NAME}/usr/bin/" + chmod +x "${PKG_NAME}/usr/bin/yap" "${PKG_NAME}/usr/bin/whisper-cli" "${PKG_NAME}/usr/bin/whisper-server" + for bin in "${PKG_NAME}/usr/bin/whisper-cli" "${PKG_NAME}/usr/bin/whisper-server"; do + ldd "$bin" | awk '/=> \/.*(whisper|ggml|gomp|openblas|blas)/ {print $3}' | xargs -r -I{} cp -L {} "${PKG_NAME}/usr/lib/yap/" + patchelf --set-rpath '$ORIGIN/../lib/yap' "$bin" + done + for lib in "${PKG_NAME}/usr/lib/yap"/*; do + [ -f "$lib" ] && patchelf --set-rpath '$ORIGIN' "$lib" || true + done cp build/appicon.iconset/icon_256x256.png "${PKG_NAME}/usr/share/icons/hicolor/256x256/apps/yap.png" - - # Create desktop entry cat > "${PKG_NAME}/usr/share/applications/yap.desktop" << EOF [Desktop Entry] Name=Yap @@ -478,22 +516,17 @@ jobs: Type=Application Categories=Utility;Audio; EOF - - # Create control file cat > "${PKG_NAME}/DEBIAN/control" << EOF Package: yap Version: ${VERSION} Section: utils Priority: optional Architecture: amd64 - Depends: libgtk-3-0, libwebkit2gtk-4.1-0, libportaudio2 + Depends: libgtk-3-0, libwebkit2gtk-4.1-0, libportaudio2, libgomp1 Maintainer: Applause Lab Description: Speech-to-Text Desktop App - Yap is a speech-to-text desktop application - powered by OpenAI Whisper API. + Yap is a speech-to-text desktop application powered by Whisper. EOF - - # Build deb package dpkg-deb --build "${PKG_NAME}" mv "${PKG_NAME}.deb" "Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu24.deb" @@ -511,7 +544,6 @@ jobs: path: Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu24.deb retention-days: 5 - # Create GitHub Release and upload all artifacts release: needs: [prepare, build-macos, build-windows, build-linux-ubuntu22, build-linux-ubuntu24] runs-on: ubuntu-latest @@ -539,37 +571,31 @@ jobs: files: | artifacts/macos-dmg/*.dmg artifacts/windows-installer/*.exe - artifacts/windows-portable/*.exe + artifacts/windows-portable/*.zip artifacts/linux-appimage-ubuntu22/*.AppImage artifacts/linux-deb-ubuntu22/*.deb artifacts/linux-appimage-ubuntu24/*.AppImage artifacts/linux-deb-ubuntu24/*.deb body: | ## Yap ${{ needs.prepare.outputs.version }} - + Speech-to-Text Desktop App by [Applause Lab](https://applauselab.ai) - + + Local transcription runtimes (`whisper-server` with `whisper-cli` fallback) are bundled with release artifacts. Whisper model files are still downloaded on first setup into the user's Yap data directory. + ### Downloads - + | Platform | Download | |----------|----------| | macOS (Apple Silicon, Intel via Rosetta) | `Yap-${{ needs.prepare.outputs.version }}-macOS.dmg` | | Windows Installer | `Yap-${{ needs.prepare.outputs.version }}-Windows-Setup.exe` | + | Windows Portable | `Yap-${{ needs.prepare.outputs.version }}-Windows-Portable.zip` | | Linux (Ubuntu 22.04, Debian 11/12) | `Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu22.AppImage` or `.deb` | | Linux (Ubuntu 24.04+) | `Yap-${{ needs.prepare.outputs.version }}-Linux-ubuntu24.AppImage` or `.deb` | - + ### Installation - + **macOS:** Open the DMG, drag to Applications folder **Windows:** Run the installer **Linux (AppImage):** `chmod +x *.AppImage && ./Yap-*.AppImage` - **Linux (DEB):** `sudo apt install ./Yap-*.deb` (auto-installs dependencies) - - ### Linux Version Guide - - | Your Distro | Use This | - |-------------|----------| - | Ubuntu 22.04 LTS, Debian 11/12, Linux Mint 21 | `ubuntu22` packages | - | Ubuntu 24.04 LTS+, Debian 13+, Linux Mint 22+ | `ubuntu24` packages | - - > **Tip:** Run `cat /etc/os-release` to check your distro version. + **Linux (DEB):** `sudo apt install ./Yap-*.deb` diff --git a/README.md b/README.md index e9c45c7..5b88fd7 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,14 @@ The app requires the following permissions: - **Microphone** — For audio recording - **Accessibility** — For global hotkey and auto-paste functionality +### Debug Logs + +Yap writes debug logs to help investigate unexpected user behavior. Logs are kept for 7 days. + +- macOS: `~/Library/Application Support/yap/logs/yap-YYYY-MM-DD.log` +- Windows: `%APPDATA%\yap\logs\yap-YYYY-MM-DD.log` +- Linux: `~/.config/yap/logs/yap-YYYY-MM-DD.log` + ## Documentation For comprehensive documentation, visit **[applauselab.ai/docs/yap](https://applauselab.ai/docs/yap/)**. diff --git a/app.go b/app.go index b0fbb5e..8068915 100644 --- a/app.go +++ b/app.go @@ -15,6 +15,7 @@ import ( "yap/internal/audio" "yap/internal/hotkey" + "yap/internal/logger" "yap/internal/models" "yap/internal/overlay" "yap/internal/sounds" @@ -94,7 +95,7 @@ type App struct { recordStartTime time.Time hotkeyEnabled bool history []HistoryItem - + // Tray callback to update icon onTrayUpdate func(recording bool) } @@ -108,40 +109,41 @@ func NewApp() *App { state: StateReady, history: make([]HistoryItem, 0), } - + // Set up overlay stop callback app.overlay.SetStopCallback(func() { app.ToggleRecording() }) - + // Set up overlay cancel callback app.overlay.SetCancelCallback(func() { app.CancelRecording() }) - + return app } // startup is called when the app starts func (a *App) startup(ctx context.Context) { a.ctx = ctx + runtime.LogInfo(a.ctx, "OnStartup: initializing") // Initialize PortAudio if err := audio.Initialize(); err != nil { - fmt.Printf("Warning: Failed to initialize audio: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize audio: %v", err)) } // Initialize native sounds if err := sounds.Init(); err != nil { - fmt.Printf("Warning: Failed to initialize sounds: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize sounds: %v", err)) } else { - fmt.Println("Native sounds initialized successfully") + runtime.LogInfo(a.ctx, "Native sounds initialized successfully") } // Initialize config manager configManager, err := models.NewConfigManager() if err != nil { - fmt.Printf("Warning: Failed to initialize config: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize config: %v", err)) return } a.configManager = configManager @@ -149,20 +151,20 @@ func (a *App) startup(ctx context.Context) { // Initialize stats manager statsManager, err := models.NewStatsManager(configManager.GetConfigDir()) if err != nil { - fmt.Printf("Warning: Failed to initialize stats manager: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize stats manager: %v", err)) } else { a.statsManager = statsManager } // Audio device preference is applied in StartRecording when recorder is created if deviceName := configManager.Get().AudioInputDevice; deviceName != "" { - fmt.Printf("Will use audio input device: %s\n", deviceName) + runtime.LogInfo(a.ctx, fmt.Sprintf("Will use audio input device: %s", deviceName)) } // Initialize model manager modelManager, err := models.NewManager(configManager.GetModelsDir()) if err != nil { - fmt.Printf("Warning: Failed to initialize model manager: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to initialize model manager: %v", err)) return } a.modelManager = modelManager @@ -172,15 +174,18 @@ func (a *App) startup(ctx context.Context) { a.localEngine.SetModel(transcribe.Model(configManager.Get().Model)) a.openaiEngine = transcribe.NewOpenAIEngine(configManager.Get().OpenAIAPIKey) + if err := a.validateReadyToRecord(); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Transcription not ready: %v", err)) + } else if configManager.Get().Provider == "local" { + a.prewarmLocalEngine() + } // Check accessibility permissions without prompting. // Onboarding handles the explicit permission request UX. if !hotkey.HasAccessibilityPermissions() { - fmt.Println("WARNING: Accessibility permissions not granted!") - fmt.Println("Escape key cancel and auto-paste features will not work.") - fmt.Println("Please grant permissions in System Preferences > Privacy & Security > Accessibility") + runtime.LogWarning(a.ctx, "Accessibility permissions not granted; escape cancel and auto-paste may not work") } else { - fmt.Println("Accessibility permissions granted") + runtime.LogInfo(a.ctx, "Accessibility permissions granted") } // Apply configured hotkey type before registering @@ -189,38 +194,51 @@ func (a *App) startup(ctx context.Context) { hotkeyType = models.DefaultRecordingHotkey() } a.hotkeyManager.SetHotkeyType(hotkeyType) - + // Apply configured cancel key cancelKey := configManager.Get().CancelHotkey if cancelKey == "" { cancelKey = "escape" } a.hotkeyManager.SetCancelKey(cancelKey) - + // Register global hotkey if err := a.hotkeyManager.Register(func() { a.ToggleRecording() }); err != nil { - fmt.Printf("Warning: Failed to register hotkey: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to register hotkey: %v", err)) } else { a.hotkeyEnabled = true - fmt.Printf("Global hotkey registered: %s\n", hotkey.GetHotkeyDisplayName(hotkeyType)) + runtime.LogInfo(a.ctx, fmt.Sprintf("Global hotkey registered: %s", hotkey.GetHotkeyDisplayName(hotkeyType))) } // Load history from disk a.loadHistory() + runtime.LogInfo(a.ctx, "OnStartup: complete") +} + +// domReady is called when the frontend has loaded its DOM. +func (a *App) domReady(ctx context.Context) { + runtime.LogInfo(ctx, "OnDomReady: frontend loaded") } // shutdown is called when the app closes func (a *App) shutdown(ctx context.Context) { + runtime.LogInfo(ctx, "OnShutdown: starting cleanup") if a.hotkeyManager != nil { a.hotkeyManager.Unregister() } if a.overlay != nil { a.overlay.Destroy() } + if a.localEngine != nil { + if err := a.localEngine.Close(); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to stop local transcription worker: %v", err)) + } + } audio.Terminate() sounds.Cleanup() + runtime.LogInfo(ctx, "OnShutdown: cleanup complete") } // GetState returns the current app state @@ -293,14 +311,14 @@ func (a *App) loadHistory() { var history []HistoryItem if err := json.Unmarshal(data, &history); err != nil { - fmt.Printf("Warning: Failed to parse history: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to parse history: %v", err)) return } a.mu.Lock() a.history = history a.mu.Unlock() - fmt.Printf("Loaded %d history items\n", len(history)) + runtime.LogInfo(a.ctx, fmt.Sprintf("Loaded %d history items", len(history))) } // saveHistory saves history to disk @@ -315,12 +333,12 @@ func (a *App) saveHistory() { a.mu.Unlock() if err != nil { - fmt.Printf("Warning: Failed to serialize history: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to serialize history: %v", err)) return } if err := os.WriteFile(path, data, 0644); err != nil { - fmt.Printf("Warning: Failed to save history: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to save history: %v", err)) } } @@ -328,7 +346,7 @@ func (a *App) saveHistory() { func (a *App) CopyHistoryItem(id string) error { a.mu.Lock() defer a.mu.Unlock() - + for _, item := range a.history { if item.ID == id { return system.CopyToClipboard(item.Text) @@ -340,7 +358,7 @@ func (a *App) CopyHistoryItem(id string) error { // DeleteHistoryItem deletes a history item by ID func (a *App) DeleteHistoryItem(id string) error { a.mu.Lock() - + // Find and remove the item var audioPath string found := false @@ -353,18 +371,18 @@ func (a *App) DeleteHistoryItem(id string) error { } } a.mu.Unlock() - + if !found { return fmt.Errorf("history item not found") } - + // Delete the audio file if it exists if audioPath != "" { if err := os.Remove(audioPath); err != nil && !os.IsNotExist(err) { - fmt.Printf("Warning: Failed to delete audio file: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to delete audio file: %v", err)) } } - + a.saveHistory() runtime.EventsEmit(a.ctx, "historyChanged", a.history) return nil @@ -400,18 +418,18 @@ func (a *App) ShowInFolder(id string) error { func (a *App) GetAudioData(id string) (string, error) { a.mu.Lock() defer a.mu.Unlock() - + for _, item := range a.history { if item.ID == id { if !item.HasAudio || item.AudioPath == "" { return "", fmt.Errorf("no audio available for this item") } - + data, err := audio.LoadWAV(item.AudioPath) if err != nil { return "", fmt.Errorf("failed to load audio: %v", err) } - + // Return as base64 return base64.StdEncoding.EncodeToString(data), nil } @@ -434,32 +452,41 @@ func (a *App) ToggleRecording() error { // StartRecording begins audio capture func (a *App) StartRecording() error { runtime.LogInfo(a.ctx, "StartRecording called") - fmt.Println("StartRecording: entering function") - + runtime.LogDebug(a.ctx, "StartRecording: entering function") + if err := a.validateReadyToRecord(); err != nil { + a.mu.Lock() + a.state = StateError + a.lastError = err.Error() + a.mu.Unlock() + runtime.LogWarning(a.ctx, fmt.Sprintf("StartRecording blocked: %v", err)) + a.emitState() + return err + } + // Save the current frontmost app before we do anything (for auto-paste later) system.SaveFrontmostApp() - + a.mu.Lock() - if a.state != StateReady { + if a.state != StateReady && a.state != StateError { a.mu.Unlock() runtime.LogWarning(a.ctx, fmt.Sprintf("Cannot start recording in state: %s", a.state)) return fmt.Errorf("cannot start recording in state: %s", a.state) } - + // Check if sound is enabled soundEnabled := a.configManager != nil && (a.configManager.Get().SoundEnabled == nil || *a.configManager.Get().SoundEnabled) - fmt.Printf("StartRecording: soundEnabled=%v\n", soundEnabled) - + runtime.LogDebug(a.ctx, fmt.Sprintf("StartRecording: soundEnabled=%v", soundEnabled)) + // Set state to recording first a.state = StateRecording a.lastError = "" a.recordStartTime = time.Now() onTrayUpdate := a.onTrayUpdate a.mu.Unlock() - + // Play start sound (non-blocking) - afplay goes to speakers, not mic input if soundEnabled { - fmt.Println("StartRecording: playing start sound") + runtime.LogDebug(a.ctx, "StartRecording: playing start sound") sounds.PlayStart() } @@ -474,7 +501,7 @@ func (a *App) StartRecording() error { // Create fresh recorder for each recording session a.recorder = audio.NewRecorder() - + // Set audio device from config if available if a.configManager != nil { config := a.configManager.Get() @@ -499,9 +526,9 @@ func (a *App) StartRecording() error { a.overlay.Show() // Enable cancel key to cancel recording (native/global) - fmt.Println("DEBUG: Enabling cancel key") + runtime.LogDebug(a.ctx, "Enabling cancel key") a.hotkeyManager.EnableCancelKey(func() { - fmt.Println("DEBUG: Cancel key callback triggered!") + runtime.LogDebug(a.ctx, "Cancel key callback triggered") a.CancelRecording() }) @@ -572,7 +599,6 @@ func (a *App) CancelRecording() error { a.emitState() - return nil } @@ -595,12 +621,22 @@ func (a *App) transcribe(samples []float32, duration float64) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() + transcriptionStarted := time.Now() if config.Provider == "openai" { text, err = a.openaiEngine.Transcribe(ctx, samples) } else { text, err = a.localEngine.Transcribe(ctx, samples) } + if err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Transcription failed: provider=%s model=%s duration=%.2fs samples=%d error=%v", config.Provider, config.Model, duration, len(samples), err)) + } else { + backend := config.Provider + if config.Provider == "local" { + backend = a.localEngine.Backend() + } + runtime.LogInfo(a.ctx, fmt.Sprintf("Transcription complete: provider=%s model=%s backend=%s audioDuration=%.2fs elapsed=%s", config.Provider, config.Model, backend, duration, time.Since(transcriptionStarted).Round(time.Millisecond))) + } // Generate unique ID for this recording recordingID := fmt.Sprintf("%d", time.Now().UnixNano()) @@ -612,11 +648,11 @@ func (a *App) transcribe(samples []float32, duration float64) { if audioDir != "" { audioPath = filepath.Join(audioDir, recordingID+".wav") if saveErr := audio.SaveWAV(audioPath, samples); saveErr != nil { - fmt.Printf("Warning: Failed to save audio: %v\n", saveErr) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to save audio: %v", saveErr)) audioPath = "" } else { hasAudio = true - fmt.Printf("Saved audio to: %s\n", audioPath) + runtime.LogInfo(a.ctx, fmt.Sprintf("Saved audio to: %s", audioPath)) } } @@ -639,7 +675,7 @@ func (a *App) transcribe(samples []float32, duration float64) { HasAudio: hasAudio, } a.history = append([]HistoryItem{historyItem}, a.history...) - + // Keep only last 50 items if len(a.history) > 50 { // Delete audio files for items being removed @@ -660,22 +696,26 @@ func (a *App) transcribe(samples []float32, duration float64) { } // Copy to clipboard and optionally paste - fmt.Printf("DEBUG: AutoPaste=%v, text length=%d\n", config.AutoPaste, len(text)) + runtime.LogDebug(a.ctx, fmt.Sprintf("AutoPaste=%v, text length=%d", config.AutoPaste, len(text))) if config.AutoPaste { go func(textToPaste string) { // Wait for the overlay to hide and the previous app to regain focus - fmt.Println("DEBUG: Waiting 500ms before paste...") + runtime.LogDebug(a.ctx, "Waiting 500ms before paste") time.Sleep(500 * time.Millisecond) - fmt.Println("DEBUG: Calling CopyAndPaste now") + runtime.LogDebug(a.ctx, "Calling CopyAndPaste now") if err := system.CopyAndPaste(textToPaste); err != nil { - fmt.Printf("Failed to paste: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to paste: %v", err)) } else { - fmt.Println("DEBUG: CopyAndPaste succeeded") + runtime.LogDebug(a.ctx, "CopyAndPaste succeeded") } }(text) } else { - fmt.Println("DEBUG: AutoPaste disabled, only copying to clipboard") - go system.CopyToClipboard(text) + runtime.LogDebug(a.ctx, "AutoPaste disabled, only copying to clipboard") + go func(textToCopy string) { + if err := system.CopyToClipboard(textToCopy); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to copy to clipboard: %v", err)) + } + }(text) } } a.mu.Unlock() @@ -698,6 +738,32 @@ func (a *App) transcribe(samples []float32, duration float64) { runtime.EventsEmit(a.ctx, "historyChanged", a.GetHistory()) } +func (a *App) validateReadyToRecord() error { + if a.configManager == nil { + return fmt.Errorf("configuration is not ready") + } + + config := a.configManager.Get() + if config.Provider == "openai" { + if a.openaiEngine == nil || !a.openaiEngine.IsAvailable() { + return fmt.Errorf("OpenAI API key is not configured") + } + return nil + } + + if a.modelManager == nil { + return fmt.Errorf("model manager is not ready") + } + if !a.modelManager.IsModelDownloaded(config.Model) { + return fmt.Errorf("model %q is not downloaded", config.Model) + } + if a.localEngine == nil || !a.localEngine.IsAvailable() { + return fmt.Errorf("local transcription is not available; install whisper-cli and download the selected model") + } + + return nil +} + // emitState sends the current state to the frontend func (a *App) emitState() { if a.ctx != nil { @@ -727,10 +793,16 @@ func (a *App) GetModels() []ModelInfo { // SetModel changes the current model func (a *App) SetModel(model string) error { + if a.configManager.Get().Provider == "local" && !a.modelManager.IsModelDownloaded(model) { + return fmt.Errorf("model %q is not downloaded", model) + } if err := a.configManager.SetModel(model); err != nil { return err } a.localEngine.SetModel(transcribe.Model(model)) + if a.configManager.Get().Provider == "local" { + a.prewarmLocalEngine() + } a.emitState() return nil } @@ -740,10 +812,32 @@ func (a *App) SetProvider(provider string) error { if err := a.configManager.SetProvider(provider); err != nil { return err } + if provider == "local" { + a.localEngine.Open() + a.prewarmLocalEngine() + } else if a.localEngine != nil { + _ = a.localEngine.Close() + } a.emitState() return nil } +func (a *App) prewarmLocalEngine() { + if a.localEngine == nil { + return + } + go func() { + started := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := a.localEngine.Prewarm(ctx); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Persistent Whisper prewarm failed: %v", err)) + return + } + runtime.LogInfo(a.ctx, fmt.Sprintf("Persistent Whisper prewarm complete: backend=%s elapsed=%s", a.localEngine.Backend(), time.Since(started).Round(time.Millisecond))) + }() +} + // SetOpenAIKey sets the OpenAI API key func (a *App) SetOpenAIKey(key string) error { if err := a.configManager.SetOpenAIAPIKey(key); err != nil { @@ -812,7 +906,7 @@ func (a *App) GetStats() UsageStats { if a.statsManager == nil { return UsageStats{} } - + stats := a.statsManager.Get() return UsageStats{ AverageWPM: a.statsManager.GetAverageWPM(), @@ -831,12 +925,12 @@ func (a *App) SetRecordingHotkey(keyName string) error { if keyName == "" { return fmt.Errorf("hotkey cannot be empty") } - + // Update the hotkey manager if a.hotkeyManager != nil { a.hotkeyManager.SetHotkeyType(keyName) } - + // Save to config return a.configManager.SetRecordingHotkey(keyName) } @@ -860,12 +954,12 @@ func (a *App) SetCancelHotkey(keyName string) error { if keyName == "" { return fmt.Errorf("cancel hotkey cannot be empty") } - + // Update the hotkey manager if a.hotkeyManager != nil { a.hotkeyManager.SetCancelKey(keyName) } - + // Save to config return a.configManager.SetCancelHotkey(keyName) } @@ -887,6 +981,11 @@ func (a *App) GetPlatform() string { return goruntime.GOOS } +// GetLogsDir returns the directory where debug logs are written. +func (a *App) GetLogsDir() string { + return logger.GetLogsDir() +} + // GetRecordingHotkeyDisplayName returns the display name for the current hotkey func (a *App) GetRecordingHotkeyDisplayName() string { return hotkey.GetHotkeyDisplayName(a.GetRecordingHotkey()) @@ -908,7 +1007,7 @@ func (a *App) RequestAccessibilityPermission() bool { if granted { // Re-register hotkey now that we have permissions if err := a.ReregisterHotkey(); err != nil { - fmt.Printf("Warning: Failed to re-register hotkey after permission grant: %v\n", err) + runtime.LogWarning(a.ctx, fmt.Sprintf("Failed to re-register hotkey after permission grant: %v", err)) } } return granted @@ -920,23 +1019,23 @@ func (a *App) ReregisterHotkey() error { if a.hotkeyManager == nil { return fmt.Errorf("hotkey manager not initialized") } - + // Unregister current hotkey a.hotkeyManager.Unregister() - + // Re-apply hotkey settings hotkeyType := a.configManager.Get().RecordingHotkey if hotkeyType == "" { hotkeyType = models.DefaultRecordingHotkey() } a.hotkeyManager.SetHotkeyType(hotkeyType) - + cancelKey := a.configManager.Get().CancelHotkey if cancelKey == "" { cancelKey = "escape" } a.hotkeyManager.SetCancelKey(cancelKey) - + // Register the hotkey if err := a.hotkeyManager.Register(func() { a.ToggleRecording() @@ -944,9 +1043,9 @@ func (a *App) ReregisterHotkey() error { a.hotkeyEnabled = false return fmt.Errorf("failed to register hotkey: %w", err) } - + a.hotkeyEnabled = true - fmt.Printf("Hotkey re-registered: %s\n", hotkey.GetHotkeyDisplayName(hotkeyType)) + runtime.LogInfo(a.ctx, fmt.Sprintf("Hotkey re-registered: %s", hotkey.GetHotkeyDisplayName(hotkeyType))) return nil } diff --git a/build/.DS_Store b/build/.DS_Store deleted file mode 100644 index 27d0c51..0000000 Binary files a/build/.DS_Store and /dev/null differ diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist index 14121ef..f9178dc 100644 --- a/build/darwin/Info.dev.plist +++ b/build/darwin/Info.dev.plist @@ -23,6 +23,8 @@ true NSHumanReadableCopyright {{.Info.Copyright}} + NSMicrophoneUsageDescription + Yap needs microphone access to record your voice for speech-to-text transcription. {{if .Info.FileAssociations}} CFBundleDocumentTypes diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi index d2d05cb..3f8f028 100644 --- a/build/windows/installer/project.nsi +++ b/build/windows/installer/project.nsi @@ -91,6 +91,11 @@ Section ; Include PortAudio DLL for audio recording functionality File "..\..\bin\portaudio.dll" + ; Include persistent Whisper worker, CLI fallback, and runtime DLLs + SetOutPath $INSTDIR\bin + File /r "..\..\bin\bin\*.*" + SetOutPath $INSTDIR + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" @@ -107,6 +112,7 @@ Section "uninstall" ; Remove PortAudio DLL Delete "$INSTDIR\portaudio.dll" + RMDir /r "$INSTDIR\bin" RMDir /r $INSTDIR diff --git a/frontend/package-lock.json b/frontend/package-lock.json index efb3030..f79b3ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.2.0", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.2.0", + "version": "0.4.2", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/frontend/package.json b/frontend/package.json index 43c15a2..50bf032 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.4.0", + "version": "0.4.2", "type": "module", "scripts": { "dev": "vite", @@ -19,4 +19,4 @@ "typescript": "^4.6.4", "vite": "^3.0.7" } -} \ No newline at end of file +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ebfbdac..ff76a6d 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -a93b9b9ee0731e091d302c100808c109 \ No newline at end of file +2a4ee46ca604023a8c5ca977c49538ff \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 254dc3e..0ce6706 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -600,6 +600,15 @@ body { background: var(--bg-hover); } +.action-item.disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.action-item.disabled:hover { + background: var(--bg-sidebar); +} + .action-icon { width: 32px; height: 32px; @@ -924,6 +933,13 @@ body { color: var(--success); } +.hotkey-status p { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); +} + .settings-footer { display: flex; justify-content: space-between; @@ -1175,6 +1191,177 @@ body { font-size: 14px; } +/* Accessibility recovery after app updates or permission revocation */ +.accessibility-recovery { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 48px 32px 32px; + overflow-y: auto; + background: + radial-gradient(circle at 50% -15%, rgba(0, 255, 78, 0.14), transparent 48%), + linear-gradient(145deg, #18191a 0%, var(--bg-dark) 58%, #151716 100%); + --wails-draggable: drag; +} + +.accessibility-recovery-card { + width: min(620px, 100%); + padding: 42px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 24px; + background: rgba(44, 44, 46, 0.88); + box-shadow: 0 28px 70px rgba(0, 0, 0, 0.38); + backdrop-filter: blur(22px); + --wails-draggable: no-drag; +} + +.accessibility-recovery-icon { + width: 58px; + height: 58px; + display: grid; + place-items: center; + margin-bottom: 28px; + border: 1px solid rgba(0, 255, 78, 0.3); + border-radius: 18px; + color: var(--accent); + background: rgba(0, 255, 78, 0.09); +} + +.accessibility-recovery-icon.complete { + color: var(--success); + background: rgba(48, 209, 88, 0.12); +} + +.accessibility-recovery-icon svg { + width: 29px; + height: 29px; +} + +.accessibility-recovery-copy { + max-width: 520px; +} + +.accessibility-recovery-eyebrow { + display: block; + margin-bottom: 10px; + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.accessibility-recovery-copy h1 { + margin-bottom: 14px; + font-size: clamp(26px, 4vw, 34px); + line-height: 1.12; + letter-spacing: -0.025em; +} + +.accessibility-recovery-copy > p { + color: var(--text-secondary); + font-size: 15px; + line-height: 1.55; +} + +.accessibility-recovery-steps { + display: grid; + grid-template-columns: 26px 1fr; + gap: 12px 14px; + margin-top: 28px; + padding: 22px; + border: 1px solid var(--border); + border-radius: 16px; + background: rgba(0, 0, 0, 0.14); +} + +.accessibility-recovery-steps span { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 50%; + color: #061208; + background: var(--accent); + font-size: 12px; + font-weight: 800; +} + +.accessibility-recovery-steps p { + align-self: center; + color: #d6d6d8; + font-size: 14px; + line-height: 1.4; +} + +.accessibility-recovery-actions { + display: flex; + gap: 10px; + margin-top: 28px; +} + +.accessibility-recovery-actions button { + min-height: 44px; + padding: 0 18px; + border-radius: 11px; + font-size: 13px; + font-weight: 650; + cursor: pointer; +} + +.accessibility-recovery-actions button:disabled { + cursor: wait; + opacity: 0.62; +} + +.recovery-primary { + border: 1px solid var(--accent); + color: #071109; + background: var(--accent); +} + +.recovery-primary:hover:not(:disabled) { + filter: brightness(1.08); +} + +.recovery-secondary { + border: 1px solid var(--border); + color: var(--text-primary); + background: rgba(255, 255, 255, 0.04); +} + +.recovery-secondary:hover:not(:disabled) { + background: var(--bg-hover); +} + +.accessibility-recovery-error { + margin-top: 16px; + padding: 12px 14px; + border: 1px solid rgba(255, 69, 58, 0.25); + border-radius: 10px; + color: #ff9a94; + background: rgba(255, 69, 58, 0.08); + font-size: 12px; + line-height: 1.45; +} + +@media (max-width: 640px) { + .accessibility-recovery { + place-items: start center; + padding: 54px 18px 24px; + } + + .accessibility-recovery-card { + padding: 28px 22px; + border-radius: 19px; + } + + .accessibility-recovery-actions { + flex-direction: column; + } +} + /* App loading state */ .app-loading { position: fixed; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e36cd00..c07f973 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,10 +30,13 @@ import { GetCancelHotkey, SetCancelHotkey, GetPlatform, + CheckAccessibilityPermission, + RequestAccessibilityPermission, + ReregisterHotkey, Quit, IsOnboardingCompleted, } from '../wailsjs/go/main/App'; -import { EventsOn, LogInfo } from '../wailsjs/runtime/runtime'; +import { EventsOn, LogDebug, LogError, LogInfo } from '../wailsjs/runtime/runtime'; interface AppState { state: string; @@ -94,8 +97,13 @@ interface UsageStats { } type Page = 'home' | 'settings' | 'history'; +type AccessibilityRecoveryState = 'checking' | 'needed' | 'recovering' | 'error' | 'complete' | 'not-needed'; function App() { + useEffect(() => { + LogInfo('[frontend] App component mounted'); + }, []); + const [appState, setAppState] = useState({ state: 'ready', recordingTime: 0, @@ -132,8 +140,11 @@ function App() { const [platform, setPlatform] = useState('darwin'); const [isCapturingHotkey, setIsCapturingHotkey] = useState(false); const [isCapturingCancelKey, setIsCapturingCancelKey] = useState(false); + const [accessibilityRecovery, setAccessibilityRecovery] = useState('checking'); + const [accessibilityRecoveryError, setAccessibilityRecoveryError] = useState(''); const hotkeyInputRef = useRef(null); const cancelKeyInputRef = useRef(null); + const accessibilityRecoveryInFlightRef = useRef(false); // Check if onboarding is needed on mount and get platform useEffect(() => { @@ -143,7 +154,99 @@ function App() { GetPlatform().then((p: string) => setPlatform(p)); }, []); + const finishAccessibilityRecovery = useCallback(async () => { + if (accessibilityRecoveryInFlightRef.current) return; + accessibilityRecoveryInFlightRef.current = true; + setAccessibilityRecovery('recovering'); + setAccessibilityRecoveryError(''); + try { + await ReregisterHotkey(); + const state = await GetState() as AppState; + if (!state.hotkeyEnabled) { + throw new Error('Yap could not register the global hotkey'); + } + setAppState(state); + setAccessibilityRecovery('complete'); + LogInfo('[frontend] Accessibility recovery completed; hotkey re-registered'); + window.setTimeout(() => setAccessibilityRecovery('not-needed'), 700); + } catch (err) { + const message = String(err); + setAccessibilityRecoveryError(message); + setAccessibilityRecovery('error'); + LogError(`[frontend] Accessibility recovery failed: ${message}`); + } finally { + accessibilityRecoveryInFlightRef.current = false; + } + }, []); + + const checkAccessibilityRecovery = useCallback(async () => { + try { + const granted = await CheckAccessibilityPermission(); + if (granted) { + await finishAccessibilityRecovery(); + } else { + setAccessibilityRecovery((current) => current === 'error' ? current : 'needed'); + } + } catch (err) { + const message = String(err); + setAccessibilityRecoveryError(message); + setAccessibilityRecovery('error'); + LogError(`[frontend] Accessibility recovery check failed: ${message}`); + } + }, [finishAccessibilityRecovery]); + + const handleOpenAccessibilitySettings = useCallback(async () => { + setAccessibilityRecovery('recovering'); + setAccessibilityRecoveryError(''); + try { + const granted = await RequestAccessibilityPermission(); + if (granted) { + await finishAccessibilityRecovery(); + } else { + setAccessibilityRecovery('needed'); + } + } catch (err) { + const message = String(err); + setAccessibilityRecoveryError(message); + setAccessibilityRecovery('error'); + LogError(`[frontend] Opening Accessibility settings failed: ${message}`); + } + }, [finishAccessibilityRecovery]); + + useEffect(() => { + if (showOnboarding !== false) return; + if (platform !== 'darwin') { + setAccessibilityRecovery('not-needed'); + return; + } + CheckAccessibilityPermission().then((granted) => { + if (granted) { + setAccessibilityRecovery('not-needed'); + } else { + setAccessibilityRecovery('needed'); + LogInfo('[frontend] Showing Accessibility recovery after startup'); + } + }).catch((err) => { + setAccessibilityRecoveryError(String(err)); + setAccessibilityRecovery('error'); + }); + }, [showOnboarding, platform]); + useEffect(() => { + if (showOnboarding !== false || platform !== 'darwin') return; + if (!['needed', 'recovering', 'error'].includes(accessibilityRecovery)) return; + + const interval = window.setInterval(checkAccessibilityRecovery, 1000); + const handleFocus = () => checkAccessibilityRecovery(); + window.addEventListener('focus', handleFocus); + return () => { + window.clearInterval(interval); + window.removeEventListener('focus', handleFocus); + }; + }, [showOnboarding, platform, accessibilityRecovery, checkAccessibilityRecovery]); + + useEffect(() => { + LogInfo('[frontend] App initial data load started'); GetState().then((state: AppState) => setAppState(state)); GetModels().then((models: ModelInfo[]) => setModels(models)); GetConfig().then((cfg: Config) => { @@ -156,13 +259,14 @@ function App() { GetStats().then((s: UsageStats) => setStats(s)); GetRecordingHotkey().then((h: string) => setCurrentHotkey(h)); GetCancelHotkey().then((h: string) => setCancelHotkey(h)); + LogInfo('[frontend] App initial data load requested'); - LogInfo('Setting up EventsOn for stateChanged'); + LogInfo('[frontend] Setting up EventsOn for stateChanged'); const cleanup = EventsOn('stateChanged', (state: AppState) => { - LogInfo('stateChanged event received: ' + state.state); + LogInfo('[frontend] stateChanged event received: ' + state.state); setAppState(state); }); - LogInfo('EventsOn setup complete'); + LogInfo('[frontend] EventsOn setup complete'); EventsOn('historyChanged', (h: HistoryItem[]) => { setHistory(h); if (h.length > 0 && !selectedHistory) { @@ -172,9 +276,15 @@ function App() { GetStats().then((s: UsageStats) => setStats(s)); }); EventsOn('downloadProgress', (progress: DownloadProgress) => setDownloadProgress(progress)); - EventsOn('downloadComplete', () => { + EventsOn('downloadComplete', async (data: { model: string }) => { setDownloadProgress(null); + try { + await SetModel(data.model); + } catch (err) { + LogError(`[frontend] SetModel after download failed: ${err}`); + } GetModels().then((models: ModelInfo[]) => setModels(models)); + GetState().then((state: AppState) => setAppState(state)); }); EventsOn('downloadError', (data: { model: string; error: string }) => { setDownloadProgress(null); @@ -223,11 +333,11 @@ function App() { } // Sound playback is handled in Go before recording starts await ToggleRecording(); - } catch (err) { console.error(err); } + } catch (err) { LogError(`[frontend] ToggleRecording failed: ${err}`); } }, [appState.state]); const handleCancelRecording = useCallback(async () => { - try { await CancelRecording(); } catch (err) { console.error(err); } + try { await CancelRecording(); } catch (err) { LogError(`[frontend] CancelRecording failed: ${err}`); } }, []); // Global escape key handler - always active at app level @@ -253,7 +363,7 @@ function App() { }, [audioSource]); const playAudio = useCallback(async (id: string) => { - console.log('playAudio called with id:', id); + LogDebug(`[frontend] playAudio called with id: ${id}`); // Stop any currently playing audio - wrap in try-catch to handle already-stopped sources if (audioSource) { @@ -267,9 +377,9 @@ function App() { try { // Get audio data as base64 - console.log('Fetching audio data...'); + LogDebug('[frontend] Fetching audio data'); const base64Data = await GetAudioData(id); - console.log('Got audio data, length:', base64Data.length); + LogDebug(`[frontend] Got audio data, length: ${base64Data.length}`); // Decode base64 to binary const binaryString = atob(base64Data); @@ -277,7 +387,7 @@ function App() { for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } - console.log('Decoded bytes:', bytes.length); + LogDebug(`[frontend] Decoded bytes: ${bytes.length}`); // Create or reuse AudioContext (recreate if closed) let ctx = audioContext; @@ -285,7 +395,7 @@ function App() { ctx = new AudioContext(); setAudioContext(ctx); } - console.log('AudioContext state:', ctx.state); + LogDebug(`[frontend] AudioContext state: ${ctx.state}`); // Resume if suspended (needed for some browsers) if (ctx.state === 'suspended') { @@ -293,9 +403,9 @@ function App() { } // Decode audio data - console.log('Decoding audio buffer...'); + LogDebug('[frontend] Decoding audio buffer'); const audioBuffer = await ctx.decodeAudioData(bytes.buffer); - console.log('Audio buffer decoded, duration:', audioBuffer.duration); + LogDebug(`[frontend] Audio buffer decoded, duration: ${audioBuffer.duration}`); // Create and play source const source = ctx.createBufferSource(); @@ -306,20 +416,26 @@ function App() { setAudioSource(null); }; source.start(); - console.log('Audio playback started'); + LogDebug('[frontend] Audio playback started'); setAudioSource(source); setIsPlaying(true); } catch (err) { - console.error('Failed to play audio:', err); + LogError(`[frontend] Failed to play audio: ${err}`); setIsPlaying(false); } }, [audioContext, audioSource]); const handleModelChange = useCallback(async (model: string) => { - await SetModel(model); + const modelInfo = models.find(m => m.name === model); + if (modelInfo?.downloaded) { + await SetModel(model); + GetState().then((s: AppState) => setAppState(s)); + } else { + await DownloadModel(model); + } GetModels().then((m: ModelInfo[]) => setModels(m)); - }, []); + }, [models]); const handleProviderChange = useCallback(async (provider: string) => { await SetProvider(provider); @@ -355,7 +471,7 @@ function App() { try { await SetRecordingHotkey(keyName); } catch (error) { - console.error('Failed to set hotkey:', error); + LogError(`[frontend] Failed to set hotkey: ${error}`); } }, []); @@ -364,7 +480,7 @@ function App() { try { await SetCancelHotkey(keyName); } catch (error) { - console.error('Failed to set cancel key:', error); + LogError(`[frontend] Failed to set cancel key: ${error}`); } }, []); @@ -550,6 +666,73 @@ function App() { return ; } + if (accessibilityRecovery === 'checking') { + return ( +
+
+
+ ); + } + + if (accessibilityRecovery !== 'not-needed') { + const isRecovering = accessibilityRecovery === 'recovering'; + const isComplete = accessibilityRecovery === 'complete'; + return ( +
+
+
+ {isComplete ? ( + + + + ) : ( + + + + + + )} +
+ +
+ One quick macOS check +

{isComplete ? 'Yap is ready again' : 'Yap needs Accessibility access again'}

+ {isComplete ? ( +

Your recording hotkey has been restored. Opening Yap...

+ ) : ( + <> +

After an update, macOS can stop recognizing Yap's existing permission. Your models, history, and settings are safe.

+
+ 1 +

Open Accessibility Settings.

+ 2 +

If Yap is already enabled, toggle it off and back on.

+ 3 +

Return here. Yap will detect access automatically.

+
+ + )} +
+ + {!isComplete && ( +
+ + +
+ )} + + {accessibilityRecovery === 'error' && ( +

{accessibilityRecoveryError || 'Yap still cannot register the recording hotkey. Toggle access off and back on, then check again.'}

+ )} +
+
+ ); + } + return ( <>
@@ -663,7 +846,10 @@ function App() {

Get started

-
+
@@ -672,7 +858,9 @@ function App() {
Start recording - Turn your voice to text with a single click + + {needsDownload ? 'Download selected model first' : 'Turn your voice to text with a single click'} +
{getHotkeyShortDisplay(currentHotkey)}
@@ -913,8 +1101,11 @@ function App() {
- {appState.hotkeyEnabled ? 'Hotkeys Active' : 'Hotkeys Not Registered'} + {appState.hotkeyEnabled ? 'Hotkeys Active' : 'Hotkeys Need Accessibility Access'} + {!appState.hotkeyEnabled && platform === 'darwin' && ( +

Open System Settings → Privacy & Security → Accessibility, then toggle Yap off and back on.

+ )}
diff --git a/frontend/src/Onboarding.css b/frontend/src/Onboarding.css index ad7018f..0951b49 100644 --- a/frontend/src/Onboarding.css +++ b/frontend/src/Onboarding.css @@ -386,6 +386,18 @@ border-radius: 8px; } +.permission-warning { + width: 100%; + padding: 10px 12px; + background: rgba(255, 214, 10, 0.12); + border: 1px solid rgba(255, 214, 10, 0.28); + border-radius: 8px; + color: var(--warning); + font-size: 13px; + line-height: 1.4; + text-align: center; +} + .apikey-hint { font-size: 13px; color: var(--text-muted); diff --git a/frontend/src/Onboarding.tsx b/frontend/src/Onboarding.tsx index 91f2646..4cd542b 100644 --- a/frontend/src/Onboarding.tsx +++ b/frontend/src/Onboarding.tsx @@ -16,7 +16,7 @@ import { GetRecordingHotkeyDisplayName, GetPlatform, } from '../wailsjs/go/main/App'; -import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'; +import { EventsOn, LogError, LogInfo } from '../wailsjs/runtime/runtime'; interface ModelInfo { name: string; @@ -49,6 +49,10 @@ type Provider = 'local' | 'openai'; type HotkeyTestState = 'waiting' | 'recording' | 'success'; export function Onboarding({ onComplete }: OnboardingProps) { + useEffect(() => { + LogInfo('[frontend] Onboarding component mounted'); + }, []); + const [step, setStep] = useState('welcome'); const [models, setModels] = useState([]); const [selectedModel, setSelectedModel] = useState('base.en'); @@ -63,6 +67,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [platform, setPlatform] = useState('darwin'); const [hotkeyTestState, setHotkeyTestState] = useState('waiting'); const [testTranscript, setTestTranscript] = useState(''); + const [hotkeyTestError, setHotkeyTestError] = useState(''); const hotkeyTestStateRef = useRef('waiting'); useEffect(() => { @@ -92,8 +97,14 @@ export function Onboarding({ onComplete }: OnboardingProps) { setDownloadError(null); }; - const completeHandler = () => { + const completeHandler = async (data: { model: string }) => { setDownloadProgress(null); + try { + await SetModel(data.model); + } catch (err) { + setDownloadError(String(err)); + return; + } // Refresh models list GetModels().then((modelList: ModelInfo[]) => { setModels(modelList); @@ -107,14 +118,14 @@ export function Onboarding({ onComplete }: OnboardingProps) { setDownloadError(data.error); }; - EventsOn('downloadProgress', progressHandler); - EventsOn('downloadComplete', completeHandler); - EventsOn('downloadError', errorHandler); + const cleanupProgress = EventsOn('downloadProgress', progressHandler); + const cleanupComplete = EventsOn('downloadComplete', completeHandler); + const cleanupError = EventsOn('downloadError', errorHandler); return () => { - EventsOff('downloadProgress'); - EventsOff('downloadComplete'); - EventsOff('downloadError'); + cleanupProgress(); + cleanupComplete(); + cleanupError(); }; }, []); @@ -142,9 +153,9 @@ export function Onboarding({ onComplete }: OnboardingProps) { }, [apiKey]); const handleModelSelect = useCallback(async () => { - await SetModel(selectedModel); const model = models.find(m => m.name === selectedModel); if (model?.downloaded) { + await SetModel(selectedModel); // Model already downloaded, skip to mic permission request setStep('micRequest'); } else { @@ -160,10 +171,6 @@ export function Onboarding({ onComplete }: OnboardingProps) { await DownloadModel(selectedModel); }, [selectedModel]); - const handleSkipDownload = useCallback(() => { - setStep('micRequest'); - }, []); - const handleCheckMicPermission = useCallback(async () => { const status = await CheckMicrophonePermission(); setMicPermissionStatus(status); @@ -197,6 +204,13 @@ export function Onboarding({ onComplete }: OnboardingProps) { } }, [step, handleCheckMicPermission]); + // Skip the microphone prompt when macOS already has a grant for this app. + useEffect(() => { + if (step === 'micRequest' && micPermissionStatus === 'granted') { + setStep('micSuccess'); + } + }, [step, micPermissionStatus]); + // Poll for accessibility permission when on access request step useEffect(() => { if (step === 'accessRequest') { @@ -218,29 +232,33 @@ export function Onboarding({ onComplete }: OnboardingProps) { // Reset test state when entering this step setHotkeyTestState('waiting'); setTestTranscript(''); + setHotkeyTestError(''); hotkeyTestStateRef.current = 'waiting'; // Make sure hotkey is registered - ReregisterHotkey().catch(console.error); + ReregisterHotkey().catch((err) => LogError(`[frontend] ReregisterHotkey failed: ${err}`)); const stateHandler = (state: AppState) => { if (state.state === 'recording' && hotkeyTestStateRef.current === 'waiting') { setHotkeyTestState('recording'); + setHotkeyTestError(''); hotkeyTestStateRef.current = 'recording'; - } else if (state.state === 'ready' && hotkeyTestStateRef.current === 'recording') { + } else if (state.state === 'transcribing' && hotkeyTestStateRef.current === 'recording') { setHotkeyTestState('success'); hotkeyTestStateRef.current = 'success'; - // Capture the transcript from the test recording + } else if (state.state === 'ready' && hotkeyTestStateRef.current === 'success') { if (state.lastTranscript) { setTestTranscript(state.lastTranscript); } + } else if (state.state === 'error' && hotkeyTestStateRef.current === 'recording') { + setHotkeyTestState('waiting'); + setHotkeyTestError(state.error || 'Recording failed. Check microphone permission and try again.'); + hotkeyTestStateRef.current = 'waiting'; } }; - EventsOn('stateChanged', stateHandler); - return () => { - EventsOff('stateChanged'); - }; + const cleanup = EventsOn('stateChanged', stateHandler); + return cleanup; } }, [step]); @@ -400,7 +418,7 @@ export function Onboarding({ onComplete }: OnboardingProps) {
{model.name === 'tiny.en' && 'Fastest option, great for quick notes and simple dictation'} {model.name === 'base.en' && 'Best balance of speed and accuracy for everyday use'} - {model.name === 'small.en' && 'Higher accuracy for detailed transcription, slightly slower'} + {model.name === 'small.en' && 'Higher accuracy for detailed transcription, but much slower on CPU'}
{model.downloaded && ( @@ -477,12 +495,12 @@ export function Onboarding({ onComplete }: OnboardingProps) { ) : downloadProgress ? ( <>

Downloading {model?.displayName}

-

{model?.size} — {progress.toFixed(0)}%

+

A local model is required before Yap can transcribe offline. {model?.size} - {progress.toFixed(0)}%

) : ( <>

Preparing Download

-

Setting up {model?.displayName}...

+

A local model is required before Yap can transcribe offline.

)}
@@ -490,18 +508,14 @@ export function Onboarding({ onComplete }: OnboardingProps) {
{downloadError ? ( <> - - ) : ( - - )} + ) : null}
); @@ -517,11 +531,8 @@ export function Onboarding({ onComplete }: OnboardingProps) { }, []); const renderMicRequest = () => { - // If already granted, auto-advance - if (micPermissionStatus === 'granted') { - // Use effect will handle this, but also allow manual continue - } - + const micDenied = micPermissionStatus === 'denied'; + return (
{/* Pixel-art microphone illustration */} @@ -540,17 +551,27 @@ export function Onboarding({ onComplete }: OnboardingProps) {
-

Yap needs your microphone

-

To transcribe your voice, Yap needs access to your microphone. Click below and select "Allow" in the system dialog.

+

{micDenied ? 'Microphone access is blocked' : 'Yap needs your microphone'}

+

+ {micDenied + ? 'macOS will not show the permission dialog again. Open System Settings and enable Microphone access for Yap, then come back and re-check.' + : 'To transcribe your voice, Yap needs access to your microphone. Click below and select "Allow" in the system dialog.'} +

+ + {micDenied && ( +
+ System Settings → Privacy & Security → Microphone → Yap +
+ )}
- {micPermissionStatus === 'granted' ? ( - ) : (

- Click the button below to open System Settings, then toggle Yap on in the Accessibility list. + Click the button below to open System Settings, then toggle Yap on in the Accessibility list. If Yap already appears enabled but this step does not advance, toggle it off and back on.

+ + {accessibilityStatus === 'denied' && ( +
+ System Settings → Privacy & Security → Accessibility → Yap +
+ )}
+ {accessibilityStatus === 'denied' && ( + + )}
{accessibilityStatus === 'granted' && ( @@ -767,7 +799,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { {hotkeyTestState === 'waiting' && (
-

Having trouble? Make sure you granted Accessibility permission.

+

{hotkeyTestError || 'Having trouble? Make sure you granted Accessibility permission.'}

)} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 8b0b562..d3cd589 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -31,6 +31,8 @@ export function GetCurrentAudioInputDevice():Promise; export function GetHistory():Promise>; +export function GetLogsDir():Promise; + export function GetModels():Promise>; export function GetPlatform():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index e4a5b9d..8b74b60 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -58,6 +58,10 @@ export function GetHistory() { return window['go']['main']['App']['GetHistory'](); } +export function GetLogsDir() { + return window['go']['main']['App']['GetLogsDir'](); +} + export function GetModels() { return window['go']['main']['App']['GetModels'](); } diff --git a/internal/audio/recorder.go b/internal/audio/recorder.go index c8c3b22..195e300 100644 --- a/internal/audio/recorder.go +++ b/internal/audio/recorder.go @@ -8,6 +8,8 @@ import ( "sync" "time" + "yap/internal/logger" + "github.com/gordonklaus/portaudio" ) @@ -30,10 +32,10 @@ type Recorder struct { mu sync.Mutex isRecording bool startTime time.Time - deviceName string // Empty string means use system default - levelCallback func(float32) // Callback for audio level updates - levelChan chan float32 // Channel for level updates - stopLevelChan chan struct{} // Signal to stop level goroutine + deviceName string // Empty string means use system default + levelCallback func(float32) // Callback for audio level updates + levelChan chan float32 // Channel for level updates + stopLevelChan chan struct{} // Signal to stop level goroutine } // NewRecorder creates a new audio recorder @@ -149,7 +151,7 @@ func (r *Recorder) Start() error { device, findErr := findDeviceByName(r.deviceName) if findErr != nil { // Device not found, fall back to default - fmt.Printf("Warning: Device '%s' not found, using default\n", r.deviceName) + logger.Warning(fmt.Sprintf("Device %q not found, using default", r.deviceName)) stream, err = portaudio.OpenDefaultStream( Channels, 0, SampleRate, FrameSize, inputBuffer, ) @@ -180,7 +182,7 @@ func (r *Recorder) Start() error { r.stream = stream r.isRecording = true r.startTime = time.Now() - + // Initialize level channels r.levelChan = make(chan float32, 1) // Buffered, drop old values r.stopLevelChan = make(chan struct{}) @@ -193,7 +195,7 @@ func (r *Recorder) Start() error { // Start a goroutine to process level updates (prevents goroutine accumulation) go r.levelLoop() - + // Start a goroutine to read audio data go r.readLoop(inputBuffer) @@ -204,9 +206,9 @@ func (r *Recorder) Start() error { func (r *Recorder) levelLoop() { ticker := time.NewTicker(33 * time.Millisecond) // ~30fps defer ticker.Stop() - + var lastLevel float32 - + for { select { case <-r.stopLevelChan: @@ -270,7 +272,7 @@ func (r *Recorder) readLoop(inputBuffer []float32) { r.mu.Lock() levelChan = r.levelChan r.mu.Unlock() - + if levelChan != nil { select { case levelChan <- level: @@ -284,18 +286,18 @@ func (r *Recorder) readLoop(inputBuffer []float32) { // Stop ends recording and returns the recorded audio as float32 samples func (r *Recorder) Stop() ([]float32, error) { r.mu.Lock() - + if !r.isRecording { r.mu.Unlock() return nil, fmt.Errorf("not recording") } r.isRecording = false - + // Stop level loop first (this will exit the levelLoop goroutine) stopChan := r.stopLevelChan r.stopLevelChan = nil - + // Clear level channel reference (don't close - readLoop might still send) r.levelChan = nil r.levelCallback = nil @@ -303,27 +305,27 @@ func (r *Recorder) Stop() ([]float32, error) { // Stop and close stream stream := r.stream r.stream = nil - + // Return a copy of the buffer result := make([]float32, len(r.buffer)) copy(result, r.buffer) - + // Clear buffer immediately r.buffer = nil - + r.mu.Unlock() // Close stop channel outside lock to signal levelLoop to exit if stopChan != nil { close(stopChan) } - + // Stop and close PortAudio stream outside lock if stream != nil { stream.Stop() stream.Close() } - + return result, nil } @@ -363,7 +365,7 @@ func ToWAV(samples []float32) ([]byte, error) { // WAV header dataSize := uint32(len(int16Samples) * 2) // 2 bytes per sample - fileSize := dataSize + 36 // Header is 44 bytes, minus 8 for RIFF header + fileSize := dataSize + 36 // Header is 44 bytes, minus 8 for RIFF header // RIFF header buf.WriteString("RIFF") @@ -372,8 +374,8 @@ func ToWAV(samples []float32) ([]byte, error) { // fmt chunk buf.WriteString("fmt ") - binary.Write(buf, binary.LittleEndian, uint32(16)) // Chunk size - binary.Write(buf, binary.LittleEndian, uint16(1)) // Audio format (PCM) + binary.Write(buf, binary.LittleEndian, uint32(16)) // Chunk size + binary.Write(buf, binary.LittleEndian, uint16(1)) // Audio format (PCM) binary.Write(buf, binary.LittleEndian, uint16(Channels)) binary.Write(buf, binary.LittleEndian, uint32(SampleRate)) binary.Write(buf, binary.LittleEndian, uint32(SampleRate*Channels*2)) // Byte rate diff --git a/internal/hotkey/hotkey_darwin.go b/internal/hotkey/hotkey_darwin.go index d77b11d..5f37284 100644 --- a/internal/hotkey/hotkey_darwin.go +++ b/internal/hotkey/hotkey_darwin.go @@ -63,6 +63,10 @@ static int requestAccessibilityPermissions(void) { NSLog(@"IMPORTANT: Please grant Accessibility permissions to enable hotkey features"); NSLog(@"Go to: System Preferences > Privacy & Security > Accessibility"); NSLog(@"Add and enable this application"); + dispatch_async(dispatch_get_main_queue(), ^{ + NSURL *url = [NSURL URLWithString:@"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"]; + [[NSWorkspace sharedWorkspace] openURL:url]; + }); } return trusted; } @@ -133,19 +137,19 @@ static void stopAllMonitoring(void) { static void startMonitoring(void) { stopAllMonitoring(); - + // Check accessibility permissions first if (!hasAccessibilityPermissions()) { NSLog(@"Cannot start monitoring without accessibility permissions"); return; } - + // Monitor for modifier keys (flagsChanged events) gEventMonitor = [NSEvent addGlobalMonitorForEventsMatchingMask:NSEventMaskFlagsChanged handler:^(NSEvent *event) { UInt16 keyCode = [event keyCode]; NSEventModifierFlags flags = [event modifierFlags]; - + // Check hotkey (if it's a modifier) if (gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -158,7 +162,7 @@ static void startMonitoring(void) { gHotkeyKeyDown = NO; } } - + // Check cancel key (if it's a modifier) if (gCancelKeyEnabled && gCancelIsModifier && keyCode == gCurrentCancelCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -167,43 +171,43 @@ static void startMonitoring(void) { } } }]; - + // Monitor for regular keys (keyDown events) gKeyEventMonitor = [NSEvent addGlobalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^(NSEvent *event) { UInt16 keyCode = [event keyCode]; - + // Check hotkey (if it's NOT a modifier) if (!gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { goHotkeyPressed(); } - + // Check cancel key (if it's NOT a modifier) if (gCancelKeyEnabled && !gCancelIsModifier && keyCode == gCurrentCancelCode) { goCancelPressed(); } }]; - + // Local monitor for regular keys when this app has focus gLocalKeyEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^NSEvent *(NSEvent *event) { UInt16 keyCode = [event keyCode]; - + // Check hotkey (if it's NOT a modifier) if (!gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { goHotkeyPressed(); return nil; // Consume event } - + // Check cancel key (if it's NOT a modifier) if (gCancelKeyEnabled && !gCancelIsModifier && keyCode == gCurrentCancelCode) { goCancelPressed(); return nil; // Consume event } - + return event; }]; - + // Also add local monitor for modifier keys (flagsChanged) when app has focus // This ensures modifier hotkeys work even when the app window is focused if (gLocalFlagsMonitor != nil) { @@ -214,7 +218,7 @@ static void startMonitoring(void) { handler:^NSEvent *(NSEvent *event) { UInt16 keyCode = [event keyCode]; NSEventModifierFlags flags = [event modifierFlags]; - + // Check hotkey (if it's a modifier) if (gHotkeyIsModifier && keyCode == gCurrentHotkeyCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -227,7 +231,7 @@ static void startMonitoring(void) { gHotkeyKeyDown = NO; } } - + // Check cancel key (if it's a modifier) if (gCancelKeyEnabled && gCancelIsModifier && keyCode == gCurrentCancelCode) { NSEventModifierFlags modFlag = getModifierFlag(keyCode); @@ -235,10 +239,10 @@ static void startMonitoring(void) { goCancelPressed(); } } - + return event; }]; - + NSLog(@"Key monitoring started - hotkey: %d, cancel: %d", gCurrentHotkeyCode, gCurrentCancelCode); } @@ -271,6 +275,8 @@ import ( "fmt" "strings" "sync" + + "yap/internal/logger" ) var ( @@ -305,11 +311,11 @@ type Callback func() // Manager handles global hotkey registration type Manager struct { - mu sync.Mutex - running bool - stopC chan struct{} - hotkeyStr string - cancelStr string + mu sync.Mutex + running bool + stopC chan struct{} + hotkeyStr string + cancelStr string } // NewManager creates a new hotkey manager @@ -329,6 +335,10 @@ func (m *Manager) Register(cb Callback) error { return nil } + if !HasAccessibilityPermissions() { + return fmt.Errorf("accessibility permission not granted") + } + callbackMu.Lock() hotkeyCallback = cb callbackMu.Unlock() @@ -336,7 +346,7 @@ func (m *Manager) Register(cb Callback) error { // Set the hotkey code keyCode := KeyNameToCode(m.hotkeyStr) C.setHotkeyCode(C.UInt16(keyCode)) - + // Set the cancel key code cancelCode := KeyNameToCode(m.cancelStr) C.setCancelCode(C.UInt16(cancelCode)) @@ -386,28 +396,28 @@ func (m *Manager) Unregister() error { func (m *Manager) SetHotkeyType(hotkeyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.hotkeyStr = strings.ToLower(hotkeyName) keyCode := KeyNameToCode(m.hotkeyStr) C.setHotkeyCode(C.UInt16(keyCode)) - + if m.running { C.startMonitoring() } - - fmt.Printf("Hotkey set to: %s (code: %d)\n", m.hotkeyStr, keyCode) + + logger.Info(fmt.Sprintf("Hotkey set to: %s (code: %d)", m.hotkeyStr, keyCode)) } // SetCancelKey sets the cancel hotkey by name func (m *Manager) SetCancelKey(keyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.cancelStr = strings.ToLower(keyName) cancelCode := KeyNameToCode(m.cancelStr) C.setCancelCode(C.UInt16(cancelCode)) - - fmt.Printf("Cancel key set to: %s (code: %d)\n", m.cancelStr, cancelCode) + + logger.Info(fmt.Sprintf("Cancel key set to: %s (code: %d)", m.cancelStr, cancelCode)) } // IsRegistered returns whether hotkey is registered @@ -422,14 +432,14 @@ func (m *Manager) EnableCancelKey(cb func()) { cancelCallbackMu.Lock() cancelCallback = cb cancelCallbackMu.Unlock() - + C.enableCancelKey() } // DisableCancelKey stops monitoring for the cancel key func (m *Manager) DisableCancelKey() { C.disableCancelKey() - + cancelCallbackMu.Lock() cancelCallback = nil cancelCallbackMu.Unlock() @@ -474,7 +484,7 @@ func KeyNameToCode(name string) uint16 { return 0x3F case "capslock": return 0x39 - + // Special keys case "escape", "esc": return 0x35 @@ -488,7 +498,7 @@ func KeyNameToCode(name string) uint16 { return 0x33 case "forwarddelete": return 0x75 - + // Arrow keys case "left", "arrowleft": return 0x7B @@ -498,7 +508,7 @@ func KeyNameToCode(name string) uint16 { return 0x7E case "down", "arrowdown": return 0x7D - + // Function keys case "f1": return 0x7A @@ -524,7 +534,7 @@ func KeyNameToCode(name string) uint16 { return 0x67 case "f12": return 0x6F - + // Letter keys case "a": return 0x00 @@ -578,7 +588,7 @@ func KeyNameToCode(name string) uint16 { return 0x10 case "z": return 0x06 - + // Number keys case "0": return 0x1D @@ -600,7 +610,7 @@ func KeyNameToCode(name string) uint16 { return 0x1C case "9": return 0x19 - + default: return 0x3D // Default to right option } diff --git a/internal/hotkey/hotkey_linux.go b/internal/hotkey/hotkey_linux.go index 6cfb3d6..eb6db7c 100644 --- a/internal/hotkey/hotkey_linux.go +++ b/internal/hotkey/hotkey_linux.go @@ -23,7 +23,7 @@ extern void goCancelPressed(void); static int initDisplay(void) { if (display != NULL) return 1; - + display = XOpenDisplay(NULL); if (display == NULL) { fprintf(stderr, "Cannot open X display\n"); @@ -61,10 +61,10 @@ static void disableCancel(void) { static void startMonitoring(void) { if (display == NULL) return; running = 1; - + // Grab the hotkey XGrabKey(display, hotkeyCode, AnyModifier, root, True, GrabModeAsync, GrabModeAsync); - + // Also grab common modifier combinations XGrabKey(display, hotkeyCode, Mod2Mask, root, True, GrabModeAsync, GrabModeAsync); XGrabKey(display, hotkeyCode, LockMask, root, True, GrabModeAsync, GrabModeAsync); @@ -74,7 +74,7 @@ static void startMonitoring(void) { static void stopMonitoring(void) { if (display == NULL) return; running = 0; - + XUngrabKey(display, hotkeyCode, AnyModifier, root); XUngrabKey(display, hotkeyCode, Mod2Mask, root); XUngrabKey(display, hotkeyCode, LockMask, root); @@ -83,18 +83,18 @@ static void stopMonitoring(void) { static void processEvents(void) { if (display == NULL || !running) return; - + XEvent event; while (XPending(display) > 0) { XNextEvent(display, &event); - + if (event.type == KeyPress) { KeyCode keycode = event.xkey.keycode; - + if (keycode == hotkeyCode) { goHotkeyPressed(); } - + if (cancelEnabled && keycode == cancelCode) { goCancelPressed(); } @@ -114,6 +114,8 @@ import ( "strings" "sync" "time" + + "yap/internal/logger" ) // Callback is the function type for hotkey events @@ -132,10 +134,10 @@ type Manager struct { } var ( - callbackMu sync.Mutex - hotkeyCallback Callback + callbackMu sync.Mutex + hotkeyCallback Callback cancelCallbackMu sync.Mutex - cancelCallback func() + cancelCallback func() ) //export goHotkeyPressed @@ -187,7 +189,7 @@ func (m *Manager) Register(cb Callback) error { // Set the hotkey keysym := KeyNameToKeysym(m.hotkeyStr) C.setHotkeyKeysym(C.KeySym(keysym)) - + // Set the cancel key cancelKeysym := KeyNameToKeysym(m.cancelStr) C.setCancelKeysym(C.KeySym(cancelKeysym)) @@ -201,7 +203,7 @@ func (m *Manager) Register(cb Callback) error { go func() { ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() - + for { select { case <-m.stopCh: @@ -212,7 +214,7 @@ func (m *Manager) Register(cb Callback) error { } }() - fmt.Printf("Hotkey registered: %s\n", m.hotkeyStr) + logger.Info(fmt.Sprintf("Hotkey registered: %s", m.hotkeyStr)) return nil } @@ -237,10 +239,10 @@ func (m *Manager) Unregister() error { func (m *Manager) SetHotkeyType(hotkeyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.hotkeyStr = strings.ToLower(hotkeyName) keysym := KeyNameToKeysym(m.hotkeyStr) - + if m.running { C.stopMonitoring() C.setHotkeyKeysym(C.KeySym(keysym)) @@ -248,20 +250,20 @@ func (m *Manager) SetHotkeyType(hotkeyName string) { } else { C.setHotkeyKeysym(C.KeySym(keysym)) } - - fmt.Printf("Hotkey set to: %s\n", m.hotkeyStr) + + logger.Info(fmt.Sprintf("Hotkey set to: %s", m.hotkeyStr)) } // SetCancelKey sets the cancel hotkey by name func (m *Manager) SetCancelKey(keyName string) { m.mu.Lock() defer m.mu.Unlock() - + m.cancelStr = strings.ToLower(keyName) keysym := KeyNameToKeysym(m.cancelStr) C.setCancelKeysym(C.KeySym(keysym)) - - fmt.Printf("Cancel key set to: %s\n", m.cancelStr) + + logger.Info(fmt.Sprintf("Cancel key set to: %s", m.cancelStr)) } // IsRegistered returns whether hotkey is registered @@ -276,23 +278,23 @@ func (m *Manager) EnableCancelKey(cb func()) { cancelCallbackMu.Lock() cancelCallback = cb cancelCallbackMu.Unlock() - + m.mu.Lock() m.cancelCallback = cb m.cancelEnabled = true m.mu.Unlock() - + C.enableCancel() } // DisableCancelKey stops monitoring for the cancel key func (m *Manager) DisableCancelKey() { C.disableCancel() - + cancelCallbackMu.Lock() cancelCallback = nil cancelCallbackMu.Unlock() - + m.mu.Lock() m.cancelEnabled = false m.cancelCallback = nil @@ -336,7 +338,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFFEB // XK_Super_L case "capslock": return 0xFFE5 // XK_Caps_Lock - + // Special keys case "escape", "esc": return 0xFF1B // XK_Escape @@ -350,7 +352,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFF08 // XK_BackSpace case "delete": return 0xFFFF // XK_Delete - + // Arrow keys case "left", "arrowleft": return 0xFF51 // XK_Left @@ -360,7 +362,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFF52 // XK_Up case "down", "arrowdown": return 0xFF54 // XK_Down - + // Function keys case "f1": return 0xFFBE @@ -386,7 +388,7 @@ func KeyNameToKeysym(name string) uint64 { return 0xFFC8 case "f12": return 0xFFC9 - + // Letter keys (lowercase) case "a": return 0x0061 @@ -440,7 +442,7 @@ func KeyNameToKeysym(name string) uint64 { return 0x0079 case "z": return 0x007A - + // Number keys case "0": return 0x0030 @@ -462,7 +464,7 @@ func KeyNameToKeysym(name string) uint64 { return 0x0038 case "9": return 0x0039 - + default: return 0xFFEA // Default to right alt } diff --git a/internal/hotkey/hotkey_other.go b/internal/hotkey/hotkey_other.go index a6dc62c..dd36d17 100644 --- a/internal/hotkey/hotkey_other.go +++ b/internal/hotkey/hotkey_other.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "sync" + + "yap/internal/logger" ) // Callback is called when the hotkey is pressed @@ -36,7 +38,7 @@ func (m *Manager) Register(callback Callback) error { m.running = true // Hotkeys not supported on this platform - fmt.Println("Warning: Hotkeys are not supported on this platform") + logger.Warning("Hotkeys are not supported on this platform") return nil } diff --git a/internal/hotkey/hotkey_windows.go b/internal/hotkey/hotkey_windows.go index 233f485..ee8ebaf 100644 --- a/internal/hotkey/hotkey_windows.go +++ b/internal/hotkey/hotkey_windows.go @@ -9,6 +9,8 @@ import ( "sync" "unsafe" + "yap/internal/logger" + "golang.org/x/sys/windows" ) @@ -199,7 +201,7 @@ func (m *Manager) Register(cb Callback) error { m.running = true m.mu.Unlock() - fmt.Printf("Keyboard hook installed, hotkey: %s (code: %d)\n", m.hotkeyStr, m.hotkeyCode) + logger.Info(fmt.Sprintf("Keyboard hook installed, hotkey: %s (code: %d)", m.hotkeyStr, m.hotkeyCode)) started <- nil // Message loop - required for the hook to work @@ -265,7 +267,7 @@ func (m *Manager) SetHotkeyType(hotkeyName string) { m.hotkeyStr = strings.ToLower(hotkeyName) m.hotkeyCode = KeyNameToCode(m.hotkeyStr) - fmt.Printf("Hotkey set to: %s (code: %d)\n", m.hotkeyStr, m.hotkeyCode) + logger.Info(fmt.Sprintf("Hotkey set to: %s (code: %d)", m.hotkeyStr, m.hotkeyCode)) } // SetCancelKey sets the cancel hotkey by name @@ -276,7 +278,7 @@ func (m *Manager) SetCancelKey(keyName string) { m.cancelStr = strings.ToLower(keyName) m.cancelCode = KeyNameToCode(m.cancelStr) - fmt.Printf("Cancel key set to: %s (code: %d)\n", m.cancelStr, m.cancelCode) + logger.Info(fmt.Sprintf("Cancel key set to: %s (code: %d)", m.cancelStr, m.cancelCode)) } // IsRegistered returns whether hotkey is registered diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..87d695b --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,168 @@ +package logger + +import ( + "fmt" + "io" + "os" + "path/filepath" + goruntime "runtime" + "sync" + "time" +) + +const retentionDays = 7 + +// FileLogger implements the Wails logger interface and writes logs to disk. +type FileLogger struct { + mu sync.Mutex + file *os.File + logsDir string + writer io.Writer +} + +var defaultLogger *FileLogger + +// Init creates the process-wide logger used by Wails and package-level callers. +func Init() error { + logger, err := New() + if err != nil { + return err + } + defaultLogger = logger + return nil +} + +// New creates a file logger under the user's Yap config directory. +func New() (*FileLogger, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + + logsDir := filepath.Join(configDir, "yap", "logs") + if err := os.MkdirAll(logsDir, 0755); err != nil { + return nil, err + } + + logPath := filepath.Join(logsDir, fmt.Sprintf("yap-%s.log", time.Now().Format("2006-01-02"))) + file, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + logger := &FileLogger{ + file: file, + logsDir: logsDir, + writer: io.MultiWriter(file, os.Stdout), + } + logger.cleanOldLogs(retentionDays) + logger.Info(fmt.Sprintf("=== Yap started === os=%s arch=%s logsDir=%s", goruntime.GOOS, goruntime.GOARCH, logsDir)) + + return logger, nil +} + +// GetDefault returns the process-wide logger if it has been initialized. +func GetDefault() *FileLogger { + return defaultLogger +} + +// GetLogsDir returns the logs directory path even before the logger is initialized. +func GetLogsDir() string { + if defaultLogger != nil { + return defaultLogger.logsDir + } + configDir, err := os.UserConfigDir() + if err != nil { + return "" + } + return filepath.Join(configDir, "yap", "logs") +} + +// Close flushes the shutdown marker and closes the active log file. +func (l *FileLogger) Close() error { + if l == nil || l.file == nil { + return nil + } + l.Info("=== Yap shutdown ===") + return l.file.Close() +} + +func (l *FileLogger) write(level, message string) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + + line := fmt.Sprintf("[%s] [%s] %s\n", time.Now().Format("2006-01-02T15:04:05.000"), level, Redact(message)) + _, _ = l.writer.Write([]byte(line)) +} + +func (l *FileLogger) cleanOldLogs(keepDays int) { + entries, err := os.ReadDir(l.logsDir) + if err != nil { + return + } + + cutoff := time.Now().AddDate(0, 0, -keepDays) + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil || !info.ModTime().Before(cutoff) { + continue + } + _ = os.Remove(filepath.Join(l.logsDir, entry.Name())) + } +} + +func Print(message string) { + if defaultLogger != nil { + defaultLogger.Print(message) + } +} + +func Trace(message string) { + if defaultLogger != nil { + defaultLogger.Trace(message) + } +} + +func Debug(message string) { + if defaultLogger != nil { + defaultLogger.Debug(message) + } +} + +func Info(message string) { + if defaultLogger != nil { + defaultLogger.Info(message) + } +} + +func Warning(message string) { + if defaultLogger != nil { + defaultLogger.Warning(message) + } +} + +func Error(message string) { + if defaultLogger != nil { + defaultLogger.Error(message) + } +} + +func Fatal(message string) { + if defaultLogger != nil { + defaultLogger.Fatal(message) + } +} + +func (l *FileLogger) Print(message string) { l.write("PRINT", message) } +func (l *FileLogger) Trace(message string) { l.write("TRACE", message) } +func (l *FileLogger) Debug(message string) { l.write("DEBUG", message) } +func (l *FileLogger) Info(message string) { l.write("INFO", message) } +func (l *FileLogger) Warning(message string) { l.write("WARN", message) } +func (l *FileLogger) Error(message string) { l.write("ERROR", message) } +func (l *FileLogger) Fatal(message string) { l.write("FATAL", message) } diff --git a/internal/logger/redact.go b/internal/logger/redact.go new file mode 100644 index 0000000..6f4cd32 --- /dev/null +++ b/internal/logger/redact.go @@ -0,0 +1,17 @@ +package logger + +import "regexp" + +var sensitivePatterns = []*regexp.Regexp{ + regexp.MustCompile(`sk-proj-[A-Za-z0-9_-]{20,}`), + regexp.MustCompile(`sk-[A-Za-z0-9_-]{20,}`), + regexp.MustCompile(`Bearer\s+[A-Za-z0-9._-]{20,}`), +} + +// Redact removes sensitive values before messages are written to disk. +func Redact(message string) string { + for _, pattern := range sensitivePatterns { + message = pattern.ReplaceAllString(message, "[REDACTED]") + } + return message +} diff --git a/internal/sounds/sounds.go b/internal/sounds/sounds.go index b6272bd..c3eb836 100644 --- a/internal/sounds/sounds.go +++ b/internal/sounds/sounds.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "path/filepath" + + "yap/internal/logger" ) //go:embed start.mp3 stop.mp3 @@ -56,41 +58,41 @@ func Cleanup() { // PlayStart plays the recording start sound (non-blocking) func PlayStart() { if startSoundPath == "" { - fmt.Println("PlayStart: no sound path") + logger.Debug("PlayStart: no sound path") return } - fmt.Printf("PlayStart: playing %s\n", startSoundPath) + logger.Debug(fmt.Sprintf("PlayStart: playing %s", startSoundPath)) // Use afplay with reduced volume (0.6) cmd := exec.Command("afplay", "-v", "0.6", startSoundPath) if err := cmd.Start(); err != nil { - fmt.Printf("PlayStart error: %v\n", err) + logger.Warning(fmt.Sprintf("PlayStart error: %v", err)) } } // PlayStop plays the recording stop sound (non-blocking) func PlayStop() { if stopSoundPath == "" { - fmt.Println("PlayStop: no sound path") + logger.Debug("PlayStop: no sound path") return } - fmt.Printf("PlayStop: playing %s\n", stopSoundPath) + logger.Debug(fmt.Sprintf("PlayStop: playing %s", stopSoundPath)) // Use afplay with reduced volume (0.6) cmd := exec.Command("afplay", "-v", "0.6", stopSoundPath) if err := cmd.Start(); err != nil { - fmt.Printf("PlayStop error: %v\n", err) + logger.Warning(fmt.Sprintf("PlayStop error: %v", err)) } } // PlayStartSync plays the recording start sound and waits for it to finish func PlayStartSync() { if startSoundPath == "" { - fmt.Println("PlayStartSync: no sound path") + logger.Debug("PlayStartSync: no sound path") return } - fmt.Printf("PlayStartSync: playing %s\n", startSoundPath) + logger.Debug(fmt.Sprintf("PlayStartSync: playing %s", startSoundPath)) cmd := exec.Command("afplay", "-v", "0.6", startSoundPath) if err := cmd.Run(); err != nil { - fmt.Printf("PlayStartSync error: %v\n", err) + logger.Warning(fmt.Sprintf("PlayStartSync error: %v", err)) } } diff --git a/internal/system/clipboard_darwin.go b/internal/system/clipboard_darwin.go index a8b0bb9..d155f80 100644 --- a/internal/system/clipboard_darwin.go +++ b/internal/system/clipboard_darwin.go @@ -11,6 +11,9 @@ package system #include #include +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + // Check if we have accessibility permissions int hasAccessibilityForPaste(void) { // Check without prompting @@ -49,13 +52,13 @@ void activatePreviousApp(void) { int simulatePasteKeystroke(void) { NSLog(@"simulatePasteKeystroke: starting"); NSLog(@"simulatePasteKeystroke: current frontmost = %@", getFrontmostAppName()); - + // Check accessibility first if (!AXIsProcessTrusted()) { NSLog(@"simulatePasteKeystroke: ERROR - No accessibility permissions!"); return 0; } - + // Activate the previous app first (in case our app took focus) if (gPreviousFrontApp) { NSLog(@"simulatePasteKeystroke: activating previous app %@", gPreviousFrontApp.localizedName); @@ -63,39 +66,45 @@ int simulatePasteKeystroke(void) { usleep(100000); // 100ms for activation to complete NSLog(@"simulatePasteKeystroke: after activation, frontmost = %@", getFrontmostAppName()); } - + // Create key down event for 'v' with Command modifier CGEventRef keyDown = CGEventCreateKeyboardEvent(NULL, (CGKeyCode)kVK_ANSI_V, true); CGEventRef keyUp = CGEventCreateKeyboardEvent(NULL, (CGKeyCode)kVK_ANSI_V, false); - + if (keyDown == NULL || keyUp == NULL) { NSLog(@"simulatePasteKeystroke: failed to create events"); if (keyDown) CFRelease(keyDown); if (keyUp) CFRelease(keyUp); return 0; } - + // Set Command modifier flag CGEventSetFlags(keyDown, kCGEventFlagMaskCommand); CGEventSetFlags(keyUp, kCGEventFlagMaskCommand); - + // Post events NSLog(@"simulatePasteKeystroke: posting keyDown"); CGEventPost(kCGHIDEventTap, keyDown); usleep(50000); // 50ms delay NSLog(@"simulatePasteKeystroke: posting keyUp"); CGEventPost(kCGHIDEventTap, keyUp); - + // Release CFRelease(keyDown); CFRelease(keyUp); NSLog(@"simulatePasteKeystroke: done"); return 1; } + +#pragma clang diagnostic pop */ import "C" -import "fmt" +import ( + "fmt" + + "yap/internal/logger" +) // SaveFrontmostApp saves the current frontmost app (call before showing overlay) func SaveFrontmostApp() { @@ -104,19 +113,19 @@ func SaveFrontmostApp() { // simulatePasteMacOSNative uses CGEvent to simulate Cmd+V (more reliable than AppleScript) func simulatePasteMacOSNative() error { - fmt.Println("simulatePasteMacOSNative: checking accessibility...") + logger.Debug("simulatePasteMacOSNative: checking accessibility") hasAccess := C.hasAccessibilityForPaste() - fmt.Printf("simulatePasteMacOSNative: hasAccessibility=%d\n", hasAccess) - + logger.Debug(fmt.Sprintf("simulatePasteMacOSNative: hasAccessibility=%d", hasAccess)) + if hasAccess == 0 { - fmt.Println("ERROR: No accessibility permissions for paste! Please grant in System Settings > Privacy & Security > Accessibility") + logger.Error("No accessibility permissions for paste") return fmt.Errorf("no accessibility permissions") } - - fmt.Println("simulatePasteMacOSNative: calling C function") + + logger.Debug("simulatePasteMacOSNative: calling C function") result := C.simulatePasteKeystroke() - fmt.Printf("simulatePasteMacOSNative: result=%d\n", result) - + logger.Debug(fmt.Sprintf("simulatePasteMacOSNative: result=%d", result)) + if result == 0 { return fmt.Errorf("paste keystroke failed") } diff --git a/internal/system/microphone_darwin.go b/internal/system/microphone_darwin.go index 77cb255..8dc3e56 100644 --- a/internal/system/microphone_darwin.go +++ b/internal/system/microphone_darwin.go @@ -11,59 +11,65 @@ package system // Returns: 0 = undetermined, 1 = denied/restricted, 2 = granted static int microphonePermissionStatus(void) { - AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; - switch (status) { - case AVAuthorizationStatusNotDetermined: - return 0; - case AVAuthorizationStatusRestricted: - case AVAuthorizationStatusDenied: - return 1; - case AVAuthorizationStatusAuthorized: - return 2; - default: - return 0; + if (@available(macOS 10.14, *)) { + AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio]; + switch (status) { + case AVAuthorizationStatusNotDetermined: + return 0; + case AVAuthorizationStatusRestricted: + case AVAuthorizationStatusDenied: + return 1; + case AVAuthorizationStatusAuthorized: + return 2; + default: + return 0; + } } + return 2; } // Requests microphone permission and returns latest status. // Returns: 0 = undetermined, 1 = denied/restricted, 2 = granted static int requestMicrophonePermission(void) { - __block BOOL granted = NO; - dispatch_semaphore_t sem = dispatch_semaphore_create(0); + if (@available(macOS 10.14, *)) { + __block BOOL granted = NO; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); - [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL allowed) { - granted = allowed; - dispatch_semaphore_signal(sem); - }]; + [AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio completionHandler:^(BOOL allowed) { + granted = allowed; + dispatch_semaphore_signal(sem); + }]; - dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); - if (granted) { - return 2; + if (granted) { + return 2; + } + return microphonePermissionStatus(); } - return microphonePermissionStatus(); + return 2; } */ import "C" func CheckMicrophonePermission() string { - switch int(C.microphonePermissionStatus()) { - case 2: - return "granted" - case 1: - return "denied" - default: - return "undetermined" - } + switch int(C.microphonePermissionStatus()) { + case 2: + return "granted" + case 1: + return "denied" + default: + return "undetermined" + } } func RequestMicrophonePermission() string { - switch int(C.requestMicrophonePermission()) { - case 2: - return "granted" - case 1: - return "denied" - default: - return "undetermined" - } + switch int(C.requestMicrophonePermission()) { + case 2: + return "granted" + case 1: + return "denied" + default: + return "undetermined" + } } diff --git a/internal/transcribe/whisper_local.go b/internal/transcribe/whisper_local.go index aa0ab1c..70ca7bb 100644 --- a/internal/transcribe/whisper_local.go +++ b/internal/transcribe/whisper_local.go @@ -6,22 +6,45 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" + "sync" ) // LocalEngine uses local whisper.cpp for transcription type LocalEngine struct { - model Model - modelsDir string - whisperBin string // Path to whisper CLI binary + modelMu sync.RWMutex + model Model + modelsDir string + whisperBin string // Path to whisper CLI binary + whisperServerBin string + whisperServerSet bool + backendMu sync.Mutex + metalUnavailable bool + workerMu sync.Mutex + worker *whisperWorker + lastBackend string + serverUnavailable bool + lifecycleMu sync.Mutex + lifecycleCtx context.Context + lifecycleCancel context.CancelFunc + lifecycleGeneration uint64 + closed bool +} + +type whisperBackend struct { + path string + metal bool } // NewLocalEngine creates a new local whisper.cpp engine func NewLocalEngine(modelsDir string) *LocalEngine { - return &LocalEngine{ + engine := &LocalEngine{ model: ModelBaseEn, modelsDir: modelsDir, } + engine.lifecycleCtx, engine.lifecycleCancel = context.WithCancel(context.Background()) + return engine } // SetWhisperBinary sets the path to the whisper CLI binary @@ -29,6 +52,16 @@ func (e *LocalEngine) SetWhisperBinary(path string) { e.whisperBin = path } +// SetWhisperServerBinary sets the persistent whisper server binary path. +func (e *LocalEngine) SetWhisperServerBinary(path string) { + e.resetOperations() + e.workerMu.Lock() + defer e.workerMu.Unlock() + e.stopWorkerLocked() + e.whisperServerBin = path + e.whisperServerSet = true +} + // Transcribe converts audio samples to text func (e *LocalEngine) Transcribe(ctx context.Context, samples []float32) (string, error) { wavData, err := samplesToWAV(samples) @@ -40,31 +73,9 @@ func (e *LocalEngine) Transcribe(ctx context.Context, samples []float32) (string // TranscribeWAV transcribes WAV audio data using whisper CLI func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string, error) { - // Check if whisper binary is available - whisperBin := e.whisperBin - if whisperBin == "" { - // Try to find whisper-cli in common locations - possiblePaths := []string{ - "/opt/homebrew/bin/whisper-cli", - "/usr/local/bin/whisper-cli", - filepath.Join(os.Getenv("HOME"), ".local/bin/whisper-cli"), - } - for _, p := range possiblePaths { - if _, err := os.Stat(p); err == nil { - whisperBin = p - break - } - } - // Also try PATH lookup - if whisperBin == "" { - if p, err := exec.LookPath("whisper-cli"); err == nil { - whisperBin = p - } - } - } - - if whisperBin == "" { - return "", fmt.Errorf("whisper-cli not found. Please install whisper.cpp or set the binary path") + generation, err := e.currentOperationGeneration() + if err != nil { + return "", err } // Get model path @@ -73,12 +84,34 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string return "", fmt.Errorf("model file not found: %s. Please download the model first", modelPath) } + var workerErr error + if e.findWhisperServerBinary() != "" { + text, err := e.transcribePersistent(ctx, wavData, modelPath) + if err == nil { + return text, nil + } + workerErr = err + if !e.operationGenerationIsCurrent(generation) { + return "", workerErr + } + } + + whisperBin := e.findWhisperBinary() + if whisperBin == "" { + if workerErr != nil { + return "", fmt.Errorf("persistent whisper worker failed: %w; whisper-cli fallback not found", workerErr) + } + return "", fmt.Errorf("whisper runtime not found. Please install whisper.cpp or set the binary path") + } + // Create temp file for audio tmpFile, err := os.CreateTemp("", "whisper-input-*.wav") if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } defer os.Remove(tmpFile.Name()) + outputPath := tmpFile.Name() + ".txt" + defer os.Remove(outputPath) if _, err := tmpFile.Write(wavData); err != nil { tmpFile.Close() @@ -86,38 +119,165 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string } tmpFile.Close() - // Run whisper-cli - cmd := exec.CommandContext(ctx, whisperBin, + baseArgs := []string{ "-m", modelPath, "-f", tmpFile.Name(), "--no-timestamps", "-otxt", - ) + "-of", tmpFile.Name(), + } + for _, backend := range e.transcriptionBackends(whisperBin) { + _ = os.Remove(outputPath) + args := append([]string{}, baseArgs...) + if strings.Contains(backend.path, "libggml-cpu") { + args = append(args, "--no-gpu") + } + cmdEnv := envWithValue(os.Environ(), "GGML_BACKEND_PATH", backend.path) + cmd := exec.CommandContext(ctx, whisperBin, args...) + cmd.Env = cmdEnv - output, err := cmd.Output() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return "", fmt.Errorf("whisper failed: %s", string(exitErr.Stderr)) + output, err := cmd.CombinedOutput() + if err != nil { + if backend.metal && ctx.Err() == nil { + e.disableMetal() + continue + } + return "", e.commandError("whisper failed", err, output, whisperBin, modelPath, cmdEnv) } - return "", fmt.Errorf("whisper failed: %w", err) - } - // Clean up the output - text := strings.TrimSpace(string(output)) - return text, nil + textBytes, err := os.ReadFile(outputPath) + if err != nil { + return "", fmt.Errorf("failed to read whisper output file %q: %w; command output=%q", outputPath, err, trimCommandOutput(string(output))) + } + + e.workerMu.Lock() + e.lastBackend = "whisper-cli" + e.workerMu.Unlock() + return strings.TrimSpace(string(textBytes)), nil + } + return "", fmt.Errorf("no usable whisper backend found") } // SetModel sets the model to use func (e *LocalEngine) SetModel(model Model) error { + e.resetOperations() + e.workerMu.Lock() + defer e.workerMu.Unlock() + e.modelMu.Lock() + defer e.modelMu.Unlock() + if e.model != model { + e.stopWorkerLocked() + } e.model = model return nil } +// Close stops the persistent local transcription worker. +func (e *LocalEngine) Close() error { + e.lifecycleMu.Lock() + e.closed = true + e.lifecycleGeneration++ + e.lifecycleCancel() + e.lifecycleMu.Unlock() + e.workerMu.Lock() + defer e.workerMu.Unlock() + e.stopWorkerLocked() + return nil +} + +// Open allows worker startup after switching back to the local provider. +func (e *LocalEngine) Open() { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + if !e.closed { + return + } + e.lifecycleCtx, e.lifecycleCancel = context.WithCancel(context.Background()) + e.lifecycleGeneration++ + e.closed = false +} + // GetModel returns the current model func (e *LocalEngine) GetModel() Model { + e.modelMu.RLock() + defer e.modelMu.RUnlock() return e.model } +// Prewarm starts the persistent worker and loads the selected model. +func (e *LocalEngine) Prewarm(ctx context.Context) error { + operationCtx, cancel, err := e.operationContext(ctx) + if err != nil { + return err + } + defer cancel() + + e.workerMu.Lock() + defer e.workerMu.Unlock() + modelPath := e.getModelPath() + if _, err := os.Stat(modelPath); err != nil { + return err + } + _, err = e.ensureWorkerLocked(operationCtx, modelPath) + return err +} + +func (e *LocalEngine) operationContext(parent context.Context) (context.Context, context.CancelFunc, error) { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + if e.closed { + return nil, nil, fmt.Errorf("local transcription engine is closed") + } + ctx, cancel := context.WithCancel(parent) + stop := context.AfterFunc(e.lifecycleCtx, cancel) + return ctx, func() { + stop() + cancel() + }, nil +} + +func (e *LocalEngine) resetOperations() { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + if e.closed { + return + } + e.lifecycleCancel() + e.lifecycleCtx, e.lifecycleCancel = context.WithCancel(context.Background()) + e.lifecycleGeneration++ +} + +func (e *LocalEngine) currentOperationGeneration() (uint64, error) { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + if e.closed { + return 0, fmt.Errorf("local transcription engine is closed") + } + return e.lifecycleGeneration, nil +} + +func (e *LocalEngine) operationGenerationIsCurrent(generation uint64) bool { + e.lifecycleMu.Lock() + defer e.lifecycleMu.Unlock() + return !e.closed && e.lifecycleGeneration == generation +} + +// Backend reports the active local transcription backend. +func (e *LocalEngine) Backend() string { + e.workerMu.Lock() + defer e.workerMu.Unlock() + if e.worker != nil { + if e.worker.metal { + return "metal-worker" + } + return "cpu-worker" + } + if e.lastBackend != "" { + return e.lastBackend + } + return "not-started" +} + // IsAvailable checks if the engine is ready func (e *LocalEngine) IsAvailable() bool { // Check if model file exists @@ -126,29 +286,288 @@ func (e *LocalEngine) IsAvailable() bool { return false } - // Check if whisper binary is available + return e.findWhisperServerBinary() != "" || e.findWhisperBinary() != "" +} + +// ValidateRuntime checks that the resolved whisper-cli can launch with the same +// environment used for transcription. +func (e *LocalEngine) ValidateRuntime(ctx context.Context) error { + whisperBin := e.findWhisperBinary() + if whisperBin == "" { + return fmt.Errorf("whisper-cli not found. Please install whisper.cpp or set the binary path") + } + + cmd := exec.CommandContext(ctx, whisperBin, "--help") + cmd.Env = e.whisperEnv(os.Environ(), whisperBin) + output, err := cmd.CombinedOutput() + if err != nil { + return e.commandError("whisper runtime validation failed", err, output, whisperBin, e.getModelPath(), cmd.Env) + } + return nil +} + +func (e *LocalEngine) findWhisperBinary() string { if e.whisperBin != "" { - if _, err := exec.LookPath(e.whisperBin); err != nil { - return false + if isExecutable(e.whisperBin) { + return e.whisperBin + } + if p, err := exec.LookPath(e.whisperBin); err == nil { + return p } - return true } - // Try common paths - possiblePaths := []string{ - "/opt/homebrew/bin/whisper-cli", - "/usr/local/bin/whisper-cli", + for _, p := range bundledWhisperCandidates() { + if isExecutable(p) { + return p + } } - for _, p := range possiblePaths { - if _, err := os.Stat(p); err == nil { - return true + + for _, p := range systemWhisperCandidates() { + if isExecutable(p) { + return p + } + } + + for _, name := range whisperBinaryNames() { + if p, err := exec.LookPath(name); err == nil { + return p } } - // Also try PATH lookup - if _, err := exec.LookPath("whisper-cli"); err == nil { - return true + return "" +} + +func (e *LocalEngine) findWhisperServerBinary() string { + if e.whisperServerSet { + if isExecutable(e.whisperServerBin) { + return e.whisperServerBin + } + if p, err := exec.LookPath(e.whisperServerBin); err == nil { + return p + } + return "" + } + + for _, p := range bundledWhisperServerCandidates() { + if isExecutable(p) { + return p + } + } + for _, name := range whisperServerBinaryNames() { + if p, err := exec.LookPath(name); err == nil { + return p + } + } + return "" +} + +func bundledWhisperCandidates() []string { + exe, err := os.Executable() + if err != nil { + return nil + } + exeDir := filepath.Dir(exe) + names := whisperBinaryNames() + candidates := make([]string, 0, len(names)*4) + for _, name := range names { + switch runtime.GOOS { + case "darwin": + candidates = append(candidates, + filepath.Join(exeDir, "..", "Resources", "bin", name), + filepath.Join(exeDir, name), + ) + case "windows": + candidates = append(candidates, + filepath.Join(exeDir, "bin", name), + filepath.Join(exeDir, name), + ) + default: + candidates = append(candidates, + filepath.Join(exeDir, name), + filepath.Join(exeDir, "bin", name), + filepath.Join(exeDir, "..", "lib", "yap", "bin", name), + ) + } + } + return candidates +} + +func bundledWhisperServerCandidates() []string { + exe, err := os.Executable() + if err != nil { + return nil + } + exeDir := filepath.Dir(exe) + candidates := make([]string, 0, 4) + for _, name := range whisperServerBinaryNames() { + switch runtime.GOOS { + case "darwin": + candidates = append(candidates, filepath.Join(exeDir, "..", "Resources", "bin", name), filepath.Join(exeDir, name)) + case "windows": + candidates = append(candidates, filepath.Join(exeDir, "bin", name), filepath.Join(exeDir, name)) + default: + candidates = append(candidates, filepath.Join(exeDir, name), filepath.Join(exeDir, "bin", name), filepath.Join(exeDir, "..", "lib", "yap", "bin", name)) + } + } + return candidates +} + +func systemWhisperCandidates() []string { + home := os.Getenv("HOME") + switch runtime.GOOS { + case "darwin": + return []string{ + "/opt/homebrew/bin/whisper-cli", + "/usr/local/bin/whisper-cli", + filepath.Join(home, ".local/bin/whisper-cli"), + } + case "windows": + return []string{ + filepath.Join(os.Getenv("ProgramFiles"), "whisper.cpp", "bin", "whisper-cli.exe"), + filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "whisper.cpp", "whisper-cli.exe"), + } + default: + return []string{ + "/usr/bin/whisper-cli", + "/usr/local/bin/whisper-cli", + filepath.Join(home, ".local/bin/whisper-cli"), + } + } +} + +func whisperBinaryNames() []string { + if runtime.GOOS == "windows" { + return []string{"whisper-cli.exe", "whisper-cli"} + } + return []string{"whisper-cli"} +} + +func whisperServerBinaryNames() []string { + if runtime.GOOS == "windows" { + return []string{"whisper-server.exe", "whisper-server"} + } + return []string{"whisper-server"} +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func (e *LocalEngine) whisperEnv(env []string, whisperBin string) []string { + binDir := filepath.Dir(whisperBin) + if runtime.GOOS == "windows" { + return append(env, "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + } + + backendCandidates := []string{ + filepath.Join(binDir, "..", "libexec", preferredAppleCPUBackend()), + filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m1.so"), + filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m2_m3.so"), + filepath.Join(binDir, "..", "libexec", "libggml-cpu-apple_m4.so"), + } + for _, p := range backendCandidates { + if info, err := os.Stat(p); err == nil && !info.IsDir() { + return envWithValue(env, "GGML_BACKEND_PATH", p) + } + } + return env +} + +func (e *LocalEngine) transcriptionBackends(whisperBin string) []whisperBackend { + binDir := filepath.Dir(whisperBin) + backends := make([]whisperBackend, 0, 2) + if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" && e.metalEnabled() { + metalPath := filepath.Join(binDir, "..", "libexec", "libggml-metal.so") + if info, err := os.Stat(metalPath); err == nil && !info.IsDir() { + backends = append(backends, whisperBackend{path: metalPath, metal: true}) + } + } + + cpuPath := envValue(e.whisperEnv(nil, whisperBin), "GGML_BACKEND_PATH") + backends = append(backends, whisperBackend{path: cpuPath}) + return backends +} + +func (e *LocalEngine) metalEnabled() bool { + e.backendMu.Lock() + defer e.backendMu.Unlock() + return !e.metalUnavailable +} + +func (e *LocalEngine) disableMetal() { + e.backendMu.Lock() + e.metalUnavailable = true + e.backendMu.Unlock() +} + +func preferredAppleCPUBackend() string { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + return "libggml-cpu-apple_m1.so" + } + + brand, err := exec.Command("sysctl", "-n", "machdep.cpu.brand_string").Output() + if err != nil { + return "libggml-cpu-apple_m1.so" + } + cpu := string(brand) + switch { + case strings.Contains(cpu, "M4"): + return "libggml-cpu-apple_m4.so" + case strings.Contains(cpu, "M2"), strings.Contains(cpu, "M3"): + return "libggml-cpu-apple_m2_m3.so" + default: + return "libggml-cpu-apple_m1.so" + } +} + +func (e *LocalEngine) commandError(prefix string, err error, output []byte, whisperBin string, modelPath string, env []string) error { + backendPath := envValue(env, "GGML_BACKEND_PATH") + if backendPath == "" { + backendPath = "(unset)" + } + + return fmt.Errorf( + "%s: %w; whisperBin=%q modelPath=%q GGML_BACKEND_PATH=%q output=%q", + prefix, + err, + whisperBin, + modelPath, + backendPath, + trimCommandOutput(string(output)), + ) +} + +func envWithValue(env []string, key string, value string) []string { + prefix := key + "=" + result := make([]string, 0, len(env)+1) + for _, entry := range env { + if !strings.HasPrefix(entry, prefix) { + result = append(result, entry) + } + } + if value != "" { + result = append(result, prefix+value) + } + return result +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + return strings.TrimPrefix(entry, prefix) + } + } + return "" +} + +func trimCommandOutput(output string) string { + output = strings.TrimSpace(output) + const maxLen = 4000 + if len(output) <= maxLen { + return output } - return false + return output[:maxLen] + "..." } // Name returns the provider name @@ -158,6 +577,8 @@ func (e *LocalEngine) Name() Provider { // getModelPath returns the full path to the model file func (e *LocalEngine) getModelPath() string { + e.modelMu.RLock() + defer e.modelMu.RUnlock() modelFile := fmt.Sprintf("ggml-%s.bin", e.model) return filepath.Join(e.modelsDir, modelFile) } diff --git a/internal/transcribe/whisper_local_integration_test.go b/internal/transcribe/whisper_local_integration_test.go new file mode 100644 index 0000000..f63eafe --- /dev/null +++ b/internal/transcribe/whisper_local_integration_test.go @@ -0,0 +1,49 @@ +package transcribe + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestLocalEngineTranscribeWAVReturnsTranscriptOnly(t *testing.T) { + if os.Getenv("YAP_TEST_WHISPER_WAV") == "" { + t.Skip("set YAP_TEST_WHISPER_WAV to run") + } + + engine := NewLocalEngine(os.Getenv("YAP_TEST_WHISPER_MODELS_DIR")) + model := Model(os.Getenv("YAP_TEST_WHISPER_MODEL")) + if model == "" { + model = ModelTinyEn + } + engine.SetModel(model) + engine.SetWhisperBinary(os.Getenv("YAP_TEST_WHISPER_BIN")) + if serverBin := os.Getenv("YAP_TEST_WHISPER_SERVER"); serverBin != "" { + engine.SetWhisperServerBinary(serverBin) + } + defer engine.Close() + + wavData, err := os.ReadFile(os.Getenv("YAP_TEST_WHISPER_WAV")) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for i := 0; i < 2; i++ { + started := time.Now() + text, err := engine.TranscribeWAV(ctx, wavData) + if err != nil { + t.Fatal(err) + } + t.Logf("transcription %d backend=%s elapsed=%s text=%q", i+1, engine.Backend(), time.Since(started), text) + if strings.Contains(text, "ggml_backend") || strings.Contains(text, "whisper_model_load") { + t.Fatalf("transcript includes whisper diagnostics: %q", text) + } + if text == "" { + t.Fatal("empty transcript") + } + } +} diff --git a/internal/transcribe/whisper_local_test.go b/internal/transcribe/whisper_local_test.go new file mode 100644 index 0000000..132b8ed --- /dev/null +++ b/internal/transcribe/whisper_local_test.go @@ -0,0 +1,108 @@ +package transcribe + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestWhisperEnvUsesBackendFile(t *testing.T) { + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + libexecDir := filepath.Join(tmpDir, "libexec") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(libexecDir, 0755); err != nil { + t.Fatal(err) + } + + backendPath := filepath.Join(libexecDir, preferredAppleCPUBackend()) + if err := os.WriteFile(backendPath, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + metalPath := filepath.Join(libexecDir, "libggml-metal.so") + if err := os.WriteFile(metalPath, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(tmpDir) + env := engine.whisperEnv([]string{}, filepath.Join(binDir, "whisper-cli")) + + got := envValue(env, "GGML_BACKEND_PATH") + if got != backendPath { + t.Fatalf("GGML_BACKEND_PATH = %q, want %q", got, backendPath) + } +} + +func TestTranscribeWAVFallsBackFromMetalAndCachesFailure(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("Metal backend is only used on Apple Silicon") + } + + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + libexecDir := filepath.Join(tmpDir, "libexec") + modelsDir := filepath.Join(tmpDir, "models") + for _, dir := range []string{binDir, libexecDir, modelsDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + + metalPath := filepath.Join(libexecDir, "libggml-metal.so") + cpuPath := filepath.Join(libexecDir, preferredAppleCPUBackend()) + for _, path := range []string{metalPath, cpuPath, filepath.Join(modelsDir, "ggml-base.en.bin")} { + if err := os.WriteFile(path, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + } + + attemptLog := filepath.Join(tmpDir, "attempts.log") + whisperBin := filepath.Join(binDir, "whisper-cli") + script := `#!/bin/sh +echo "$GGML_BACKEND_PATH" >> "` + attemptLog + `" +case "$GGML_BACKEND_PATH" in + *libggml-metal.so) exit 1 ;; +esac +while [ "$#" -gt 0 ]; do + if [ "$1" = "-of" ]; then + shift + printf 'fallback transcript\n' > "$1.txt" + exit 0 + fi + shift +done +exit 2 +` + if err := os.WriteFile(whisperBin, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperBinary(whisperBin) + engine.SetWhisperServerBinary(filepath.Join(tmpDir, "missing-whisper-server")) + + for i := 0; i < 2; i++ { + text, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + if err != nil { + t.Fatal(err) + } + if text != "fallback transcript" { + t.Fatalf("transcript = %q", text) + } + } + + data, err := os.ReadFile(attemptLog) + if err != nil { + t.Fatal(err) + } + attempts := strings.Fields(string(data)) + want := []string{metalPath, cpuPath, cpuPath} + if strings.Join(attempts, "\n") != strings.Join(want, "\n") { + t.Fatalf("backend attempts = %q, want %q", attempts, want) + } +} diff --git a/internal/transcribe/whisper_worker.go b/internal/transcribe/whisper_worker.go new file mode 100644 index 0000000..63009ab --- /dev/null +++ b/internal/transcribe/whisper_worker.go @@ -0,0 +1,320 @@ +package transcribe + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "yap/internal/logger" +) + +const ( + workerStartupTimeout = 30 * time.Second + workerStopTimeout = 2 * time.Second + workerResponseLimit = 1 << 20 +) + +type whisperWorker struct { + cmd *exec.Cmd + done chan struct{} + waitErr error + client *http.Client + baseURL string + modelPath string + backendPath string + metal bool + diagnostics *lockedBuffer +} + +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return trimCommandOutput(b.buf.String()) +} + +func (e *LocalEngine) transcribePersistent(ctx context.Context, wavData []byte, modelPath string) (string, error) { + operationCtx, cancel, err := e.operationContext(ctx) + if err != nil { + return "", err + } + defer cancel() + ctx = operationCtx + + e.workerMu.Lock() + defer e.workerMu.Unlock() + + worker, err := e.ensureWorkerLocked(ctx, modelPath) + if err != nil { + if ctx.Err() == nil { + e.serverUnavailable = true + } + return "", err + } + text, err := worker.transcribe(ctx, wavData) + if err == nil { + e.lastBackend = workerBackendName(worker) + return text, nil + } + + e.stopWorkerLocked() + if !worker.metal || ctx.Err() != nil { + if ctx.Err() == nil { + e.serverUnavailable = true + } + return "", err + } + + e.disableMetal() + logger.Warning(fmt.Sprintf("Persistent Metal worker failed; retrying on CPU: %v", err)) + worker, startErr := e.startCPUWorkerLocked(ctx, modelPath) + if startErr != nil { + if ctx.Err() == nil { + e.serverUnavailable = true + } + return "", fmt.Errorf("Metal worker failed: %v; CPU worker failed to start: %w", err, startErr) + } + text, err = worker.transcribe(ctx, wavData) + if err == nil { + e.lastBackend = workerBackendName(worker) + } else { + e.stopWorkerLocked() + if ctx.Err() == nil { + e.serverUnavailable = true + } + } + return text, err +} + +func (e *LocalEngine) ensureWorkerLocked(ctx context.Context, modelPath string) (*whisperWorker, error) { + if e.serverUnavailable { + return nil, fmt.Errorf("persistent whisper worker is disabled for this session") + } + if e.worker != nil && e.worker.modelPath == modelPath && e.worker.running() { + return e.worker, nil + } + e.stopWorkerLocked() + + serverBin := e.findWhisperServerBinary() + if serverBin == "" { + return nil, fmt.Errorf("whisper-server not found") + } + binDir := filepath.Dir(serverBin) + if e.metalEnabled() { + metalPath := filepath.Join(binDir, "..", "libexec", "libggml-metal.so") + if isExecutable(metalPath) { + worker, err := e.startWorkerLocked(ctx, serverBin, modelPath, whisperBackend{path: metalPath, metal: true}) + if err == nil { + return worker, nil + } + e.disableMetal() + } + } + return e.startCPUWorkerLocked(ctx, modelPath) +} + +func (e *LocalEngine) startCPUWorkerLocked(ctx context.Context, modelPath string) (*whisperWorker, error) { + serverBin := e.findWhisperServerBinary() + if serverBin == "" { + return nil, fmt.Errorf("whisper-server not found") + } + cpuPath := envValue(e.whisperEnv(nil, serverBin), "GGML_BACKEND_PATH") + return e.startWorkerLocked(ctx, serverBin, modelPath, whisperBackend{path: cpuPath}) +} + +func (e *LocalEngine) startWorkerLocked(ctx context.Context, serverBin string, modelPath string, backend whisperBackend) (*whisperWorker, error) { + port, err := availableLoopbackPort() + if err != nil { + return nil, err + } + token, err := randomWorkerToken() + if err != nil { + return nil, err + } + requestPath := "/yap-" + token + args := []string{"-m", modelPath, "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--request-path", requestPath, "--inference-path", "/inference", "--no-timestamps"} + if strings.Contains(backend.path, "libggml-cpu") { + args = append(args, "--no-gpu") + } + + diagnostics := &lockedBuffer{} + cmd := exec.Command(serverBin, args...) + cmd.Env = envWithValue(os.Environ(), "GGML_BACKEND_PATH", backend.path) + cmd.Stdout = io.Discard + cmd.Stderr = diagnostics + if err := cmd.Start(); err != nil { + return nil, err + } + + worker := &whisperWorker{ + cmd: cmd, + done: make(chan struct{}), + client: &http.Client{Timeout: 5 * time.Minute}, + baseURL: fmt.Sprintf("http://127.0.0.1:%d%s", port, requestPath), + modelPath: modelPath, + backendPath: backend.path, + metal: backend.metal, + diagnostics: diagnostics, + } + go func() { + worker.waitErr = cmd.Wait() + close(worker.done) + }() + + startupCtx, cancel := context.WithTimeout(ctx, workerStartupTimeout) + defer cancel() + if err := worker.waitReady(startupCtx); err != nil { + worker.stop() + return nil, fmt.Errorf("worker startup failed: %w; backend=%q output=%q", err, backend.path, diagnostics.String()) + } + e.worker = worker + e.lastBackend = workerBackendName(worker) + logger.Info(fmt.Sprintf("Persistent Whisper worker ready: backend=%s model=%q pid=%d", e.lastBackend, modelPath, cmd.Process.Pid)) + return worker, nil +} + +func (e *LocalEngine) stopWorkerLocked() { + if e.worker != nil { + e.worker.stop() + e.worker = nil + } +} + +func (w *whisperWorker) waitReady(ctx context.Context) error { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, w.baseURL+"/", nil) + if err != nil { + return err + } + resp, err := w.client.Do(req) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK && resp.Header.Get("Server") == "whisper.cpp" { + return nil + } + } + select { + case <-w.done: + return fmt.Errorf("process exited: %w", w.waitErr) + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func (w *whisperWorker) transcribe(ctx context.Context, wavData []byte) (string, error) { + started := time.Now() + var body bytes.Buffer + form := multipart.NewWriter(&body) + file, err := form.CreateFormFile("file", "recording.wav") + if err != nil { + return "", err + } + if _, err := file.Write(wavData); err != nil { + return "", err + } + _ = form.WriteField("response_format", "text") + _ = form.WriteField("no_timestamps", "true") + if err := form.Close(); err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.baseURL+"/inference", &body) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", form.FormDataContentType()) + resp, err := w.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, workerResponseLimit+1)) + if err != nil { + return "", err + } + if len(data) > workerResponseLimit { + return "", fmt.Errorf("worker response exceeds %d bytes", workerResponseLimit) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("worker returned HTTP %d: %s", resp.StatusCode, trimCommandOutput(string(data))) + } + logger.Info(fmt.Sprintf("Persistent Whisper inference complete: backend=%s elapsed=%s", workerBackendName(w), time.Since(started).Round(time.Millisecond))) + return strings.TrimSpace(string(data)), nil +} + +func workerBackendName(worker *whisperWorker) string { + if worker != nil && worker.metal { + return "metal-worker" + } + return "cpu-worker" +} + +func (w *whisperWorker) running() bool { + select { + case <-w.done: + return false + default: + return w.cmd.Process != nil + } +} + +func (w *whisperWorker) stop() { + if w == nil || w.cmd.Process == nil { + return + } + _ = w.cmd.Process.Signal(os.Interrupt) + select { + case <-w.done: + return + case <-time.After(workerStopTimeout): + _ = w.cmd.Process.Kill() + select { + case <-w.done: + case <-time.After(workerStopTimeout): + } + } +} + +func availableLoopbackPort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +func randomWorkerToken() (string, error) { + data := make([]byte, 16) + if _, err := rand.Read(data); err != nil { + return "", err + } + return hex.EncodeToString(data), nil +} diff --git a/internal/transcribe/whisper_worker_test.go b/internal/transcribe/whisper_worker_test.go new file mode 100644 index 0000000..13a8d1d --- /dev/null +++ b/internal/transcribe/whisper_worker_test.go @@ -0,0 +1,292 @@ +package transcribe + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestLocalEngineReusesPersistentWorker(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test worker launcher uses a shell script") + } + + tmpDir := t.TempDir() + modelsDir := filepath.Join(tmpDir, "models") + if err := os.MkdirAll(modelsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modelsDir, "ggml-base.en.bin"), []byte("model"), 0644); err != nil { + t.Fatal(err) + } + + startLog := filepath.Join(tmpDir, "starts.log") + t.Setenv("YAP_TEST_WORKER_START_LOG", startLog) + launcher := filepath.Join(tmpDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + t.Cleanup(func() { _ = engine.Close() }) + + for i := 0; i < 2; i++ { + text, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + if err != nil { + t.Fatal(err) + } + if text != "persistent transcript" { + t.Fatalf("transcript = %q", text) + } + } + + starts, err := os.ReadFile(startLog) + if err != nil { + t.Fatal(err) + } + if got := len(strings.Fields(string(starts))); got != 1 { + t.Fatalf("worker starts = %d, want 1", got) + } +} + +func TestLocalEngineFallsBackToPersistentCPUWorker(t *testing.T) { + if runtime.GOOS != "darwin" || runtime.GOARCH != "arm64" { + t.Skip("Metal backend is only used on Apple Silicon") + } + + tmpDir := t.TempDir() + binDir := filepath.Join(tmpDir, "bin") + modelsDir := filepath.Join(tmpDir, "models") + libexecDir := filepath.Join(tmpDir, "libexec") + for _, dir := range []string{binDir, modelsDir, libexecDir} { + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + } + for _, path := range []string{ + filepath.Join(modelsDir, "ggml-base.en.bin"), + filepath.Join(libexecDir, "libggml-metal.so"), + filepath.Join(libexecDir, preferredAppleCPUBackend()), + } { + if err := os.WriteFile(path, []byte("test"), 0755); err != nil { + t.Fatal(err) + } + } + + startLog := filepath.Join(tmpDir, "starts.log") + t.Setenv("YAP_TEST_WORKER_START_LOG", startLog) + t.Setenv("YAP_TEST_WORKER_FAIL_METAL", "1") + launcher := filepath.Join(binDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + t.Cleanup(func() { _ = engine.Close() }) + for i := 0; i < 2; i++ { + text, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + if err != nil { + t.Fatal(err) + } + if text != "persistent transcript" { + t.Fatalf("transcript = %q", text) + } + } + + data, err := os.ReadFile(startLog) + if err != nil { + t.Fatal(err) + } + attempts := strings.Fields(string(data)) + want := []string{filepath.Join(libexecDir, "libggml-metal.so"), filepath.Join(libexecDir, preferredAppleCPUBackend())} + if strings.Join(attempts, "\n") != strings.Join(want, "\n") { + t.Fatalf("worker starts = %q, want %q", attempts, want) + } +} + +func TestClosedLocalEngineDoesNotStartWorker(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test worker launcher uses a shell script") + } + + tmpDir := t.TempDir() + modelsDir := filepath.Join(tmpDir, "models") + if err := os.MkdirAll(modelsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modelsDir, "ggml-base.en.bin"), []byte("model"), 0644); err != nil { + t.Fatal(err) + } + startLog := filepath.Join(tmpDir, "starts.log") + t.Setenv("YAP_TEST_WORKER_START_LOG", startLog) + launcher := filepath.Join(tmpDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + if err := engine.Close(); err != nil { + t.Fatal(err) + } + if err := engine.Prewarm(context.Background()); err == nil { + t.Fatal("Prewarm succeeded after Close") + } + if _, err := os.Stat(startLog); !os.IsNotExist(err) { + t.Fatalf("worker started after Close: %v", err) + } +} + +func TestCloseCancelsActivePersistentInference(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test worker launcher uses a shell script") + } + + tmpDir := t.TempDir() + modelsDir := filepath.Join(tmpDir, "models") + if err := os.MkdirAll(modelsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(modelsDir, "ggml-base.en.bin"), []byte("model"), 0644); err != nil { + t.Fatal(err) + } + startLog := filepath.Join(tmpDir, "starts.log") + requestLog := filepath.Join(tmpDir, "requests.log") + t.Setenv("YAP_TEST_WORKER_START_LOG", startLog) + t.Setenv("YAP_TEST_WORKER_REQUEST_LOG", requestLog) + t.Setenv("YAP_TEST_WORKER_DELAY", "10s") + launcher := filepath.Join(tmpDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + cliLog := filepath.Join(tmpDir, "cli.log") + cli := filepath.Join(tmpDir, "whisper-cli") + if err := os.WriteFile(cli, []byte("#!/bin/sh\nprintf called > "+cliLog+"\nexit 1\n"), 0755); err != nil { + t.Fatal(err) + } + engine.SetWhisperBinary(cli) + result := make(chan error, 1) + go func() { + _, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + result <- err + }() + + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(requestLog); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("worker did not receive inference request") + } + time.Sleep(10 * time.Millisecond) + } + + started := time.Now() + if err := engine.Close(); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(started); elapsed > 4*time.Second { + t.Fatalf("Close took %s", elapsed) + } + select { + case <-result: + case <-time.After(time.Second): + t.Fatal("transcription did not return after Close") + } + if _, err := os.Stat(cliLog); !os.IsNotExist(err) { + t.Fatalf("CLI fallback started after Close: %v", err) + } +} + +func TestWhisperWorkerHelper(t *testing.T) { + if os.Getenv("YAP_TEST_WORKER_START_LOG") == "" { + return + } + + args := helperArgs(os.Args) + port := argValue(args, "--port") + requestPath := argValue(args, "--request-path") + if port == "" || requestPath == "" { + os.Exit(2) + } + file, err := os.OpenFile(os.Getenv("YAP_TEST_WORKER_START_LOG"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + os.Exit(2) + } + backend := os.Getenv("GGML_BACKEND_PATH") + if backend == "" { + backend = "default" + } + _, _ = file.WriteString(backend + "\n") + _ = file.Close() + if os.Getenv("YAP_TEST_WORKER_FAIL_METAL") == "1" && strings.Contains(backend, "libggml-metal") { + os.Exit(1) + } + + mux := http.NewServeMux() + mux.HandleFunc(requestPath+"/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Server", "whisper.cpp") + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc(requestPath+"/inference", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", "whisper.cpp") + if requestLog := os.Getenv("YAP_TEST_WORKER_REQUEST_LOG"); requestLog != "" { + _ = os.WriteFile(requestLog, []byte("request\n"), 0644) + } + if delay := os.Getenv("YAP_TEST_WORKER_DELAY"); delay != "" { + if duration, err := time.ParseDuration(delay); err == nil { + time.Sleep(duration) + } + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _, _ = io.Copy(io.Discard, file) + _ = file.Close() + _, _ = io.WriteString(w, "persistent transcript\n") + }) + if err := http.ListenAndServe("127.0.0.1:"+port, mux); err != nil { + os.Exit(1) + } +} + +func helperArgs(args []string) []string { + for i, arg := range args { + if arg == "--" { + return args[i+1:] + } + } + return nil +} + +func argValue(args []string, key string) string { + for i := 0; i+1 < len(args); i++ { + if args[i] == key { + return args[i+1] + } + } + return "" +} diff --git a/main.go b/main.go index dd987a6..317135f 100644 --- a/main.go +++ b/main.go @@ -3,9 +3,11 @@ package main import ( "embed" + filelogger "yap/internal/logger" "yap/internal/tray" "github.com/wailsapp/wails/v2" + wailslogger "github.com/wailsapp/wails/v2/pkg/logger" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/mac" @@ -14,8 +16,22 @@ import ( //go:embed all:frontend/dist var assets embed.FS +var appVersion = "dev" + func main() { + if err := filelogger.Init(); err != nil { + println("Warning: Failed to initialize file logger:", err.Error()) + } + logger := filelogger.GetDefault() + defer func() { + if logger != nil { + logger.Close() + } + }() + filelogger.Info("Yap app version: " + appVersion) + app := NewApp() + filelogger.Info("App instance created") // Start systray (non-blocking with external loop) tray.Start(tray.Callbacks{ @@ -32,9 +48,11 @@ func main() { app.QuitApp() }, }) + filelogger.Info("System tray started") // Set the tray reference in app app.SetTray(tray.SetRecording) + filelogger.Info("Starting Wails runtime") err := wails.Run(&options.App{ Title: "Yap", @@ -45,11 +63,15 @@ func main() { AssetServer: &assetserver.Options{ Assets: assets, }, - BackgroundColour: &options.RGBA{R: 22, G: 19, B: 31, A: 255}, - OnStartup: app.startup, - OnShutdown: app.shutdown, - Frameless: false, - StartHidden: false, + BackgroundColour: &options.RGBA{R: 22, G: 19, B: 31, A: 255}, + OnStartup: app.startup, + OnDomReady: app.domReady, + OnShutdown: app.shutdown, + Logger: logger, + LogLevel: wailslogger.DEBUG, + LogLevelProduction: wailslogger.DEBUG, + Frameless: false, + StartHidden: false, Bind: []interface{}{ app, }, @@ -64,10 +86,10 @@ func main() { Appearance: mac.NSAppearanceNameDarkAqua, WebviewIsTransparent: false, WindowIsTranslucent: false, - About: &mac.AboutInfo{ - Title: "Yap", - Message: "Speech-to-Text Desktop App\nby applauselab.ai\nv0.2.0", - }, + About: &mac.AboutInfo{ + Title: "Yap", + Message: "Speech-to-Text Desktop App\nby applauselab.ai\nv" + appVersion, + }, }, }) diff --git a/package-lock.json b/package-lock.json index 6900925..6bf9119 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "yap", - "version": "0.1.0", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "yap", - "version": "0.1.0", + "version": "0.4.2", "devDependencies": { "@commitlint/cli": "^19.6.1", "@commitlint/config-conventional": "^19.6.0", diff --git a/package.json b/package.json index 5bc412c..428afc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yap", - "version": "0.4.0", + "version": "0.4.2", "private": true, "type": "module", "description": "Speech-to-Text Desktop App by ApplauseLab", diff --git a/wails.json b/wails.json index f2ed31f..ff75ca4 100644 --- a/wails.json +++ b/wails.json @@ -13,7 +13,7 @@ "info": { "companyName": "Applause Lab", "productName": "Yap", - "productVersion": "0.4.0", + "productVersion": "0.4.2", "copyright": "Copyright 2024 Applause Lab", "comments": "Speech-to-Text Desktop App by applauselab.ai" }