This commit is contained in:
vadimwit
2026-03-17 22:41:35 +00:00
8 changed files with 265 additions and 39 deletions
+59 -23
View File
@@ -9,33 +9,69 @@ permissions:
contents: write
jobs:
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run electron:build:win
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
WIN_CSC_LINK: ''
- uses: softprops/action-gh-release@v2
with:
files: releases/*.exe
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Ensures one OS failing doesn't kill the others
matrix:
include:
- os: windows-latest
command: npm run electron:build:win
artifact_pattern: "release/*.exe"
- os: macos-latest
# Added --universal here if you want to support both Intel and Apple Silicon
command: npm run electron:build:mac -- --universal
artifact_pattern: "release/*.dmg"
- os: ubuntu-latest
command: npm run electron:build:linux
artifact_pattern: "release/*.AppImage"
env:
# This fixes the "GitHub Personal Access Token is not set" error
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prevents errors related to missing code-signing certificates
CSC_IDENTITY_AUTO_DISCOVERY: false
build-mac:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install
- run: npm run electron:build:mac
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
- uses: softprops/action-gh-release@v2
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Build Application
run: ${{ matrix.command }}
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
files: releases/*.dmg
name: artifacts-${{ matrix.os }}
path: ${{ matrix.artifact_pattern }}
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
# This downloads all "artifacts-*" into a folder named 'all-outputs'
path: all-outputs
merge-multiple: true
- name: List files for debugging
run: ls -R all-outputs
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
# Point directly to the folder where all OS builds are merged
files: all-outputs/*
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+36
View File
@@ -70,6 +70,42 @@ npm run electron:build:linux
Output is placed in `frontend/release/`.
## Releasing / Tagging
To create a GitHub release and trigger the CI build pipeline, create an annotated tag and push it to origin. The release workflow runs on tags matching `v*` (for example `v0.6.1`).
Local tagging example:
```bash
# update package.json version first if desired
git tag -a v0.6.1 -m "Release v0.6.1"
git push origin v0.6.1
```
What the GitHub Action does (`.github/workflows/release.yml`):
- Listens for pushed tags `v*` and runs a matrix build across Windows, macOS and Linux.
- macOS is built as a universal binary (`--universal`) so a single DMG supports both Intel and Apple Silicon.
- Each matrix job builds the installer using `electron-builder`, uploads its artifacts, and a final `publish` job aggregates all artifacts into one GitHub release.
If you prefer to run builds locally before tagging, use the npm scripts in the repository root:
```bash
# Windows NSIS
npm run electron:build:win
# macOS DMG (universal)
npm run electron:build:mac -- --universal
# Linux AppImage
npm run electron:build:linux
```
CI notes / troubleshooting
- The workflow uploads artifacts from `release/` into the release. Ensure `package.json` build `directories.output` matches the workflow's expected `release/` folder.
- If mac packaging for x64 on ARM-hosted runners fails, switch to `--universal` (already configured) or build x64 on an Intel runner.
- To test the workflow locally, consider using `nektos/act` or push a temporary tag like `vtest`.
## Design Tokens
All colors are defined in `frontend/tailwind.config.js` and can be referenced by name in any component.
+68 -4
View File
@@ -1,12 +1,12 @@
{
"name": "whattheflat",
"version": "0.1.0",
"name": "jambuddy",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "whattheflat",
"version": "0.1.0",
"name": "jambuddy",
"version": "0.6.2",
"dependencies": {
"audiomotion-analyzer": "^4.5.4",
"pitchy": "^4.1.0",
@@ -2189,6 +2189,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "whattheflat",
"name": "jambuddy",
"description": "A jam session companion app that provides a chromatic tuner, chord progressions, and more.",
"version": "0.6.0",
"version": "0.6.2",
"private": true,
"type": "module",
"main": "electron/main.cjs",
@@ -13,7 +13,7 @@
"electron:build": "vite build && electron-builder",
"electron:build:win": "vite build && electron-builder --win --publish never",
"electron:build:mac": "vite build && electron-builder --mac --publish never",
"electron:build:linux": "vite build && electron-builder --linux"
"electron:build:linux": "vite build && electron-builder --linux --publish never"
},
"dependencies": {
"audiomotion-analyzer": "^4.5.4",
@@ -34,8 +34,8 @@
"wait-on": "^9.0.4"
},
"build": {
"appId": "com.whattheflat.app",
"productName": "WhatTheFlat",
"appId": "com.jambuddy.app",
"productName": "JamBuddy",
"files": [
"dist/**/*",
"electron/**/*",
+3
View File
@@ -25,6 +25,8 @@ const DEFAULTS = {
// Audio input
minClarity: 0.80,
minVolume: 0.01,
// Selected device (null = system default)
audioDeviceId: null,
}
function loadStored(key, fallback) {
@@ -514,6 +516,7 @@ export default function App() {
isListening={isListening}
minClarity={config.minClarity}
minVolume={config.minVolume}
audioDeviceId={config.audioDeviceId}
onPermissionError={() => {
setMicError(true)
setIsListening(false)
+36 -2
View File
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
return ((bestMidi % 12) + 12) % 12
}
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError }) {
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null }) {
const audioCtxRef = useRef(null)
const timeBufRef = useRef(null)
const freqBufRef = useRef(null)
@@ -121,8 +121,34 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
stop()
let stream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
// Helpful debug: list available media devices before requesting permission
try {
if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) {
const devices = await navigator.mediaDevices.enumerateDevices()
const audioIns = devices.filter(d => d.kind === 'audioinput')
console.log('Audio inputs available:', audioIns)
}
} catch (e) {
console.warn('enumerateDevices failed', e)
}
const constraints = audioDeviceId
? { audio: { deviceId: { exact: audioDeviceId } } }
: { audio: true }
console.log('Requesting getUserMedia with constraints:', constraints)
stream = await navigator.mediaDevices.getUserMedia(constraints)
} catch (err) {
// If permission denied or other error, surface extra diagnostics when possible
console.warn('getUserMedia failed', err)
try {
if (navigator.permissions && navigator.permissions.query) {
const p = await navigator.permissions.query({ name: 'microphone' })
console.log('microphone permission state:', p.state)
}
} catch (e) {
// ignore; not all environments support Permissions API for microphone
}
onPermissionErrorRef.current?.(err)
return
}
@@ -135,6 +161,14 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
audioCtxRef.current = ctx
const source = ctx.createMediaStreamSource(stream)
// Debug: log the acquired audio tracks and labels/deviceIds
try {
const tracks = stream.getAudioTracks()
console.log('Acquired audio tracks:', tracks.map(t => ({ label: t.label, id: t.id, enabled: t.enabled, muted: t.muted })))
} catch (e) {
console.warn('Could not inspect stream tracks', e)
}
// Small analyser — pitch detection needs fast time-domain data
const pa = ctx.createAnalyser()
pa.fftSize = PITCH_FFT
+45 -2
View File
@@ -1,3 +1,5 @@
import { useRef, useEffect, useState } from 'react'
const SETTINGS = [
{
section: 'Chord Detection',
@@ -70,8 +72,6 @@ const SETTINGS = [
},
]
import { useRef } from 'react'
export default function Settings({ config, onChange, onClose, onReset, monoColor, onMonoColorChange }) {
// Snapshot on mount so Cancel can restore
const savedConfig = useRef(config)
@@ -83,6 +83,41 @@ export default function Settings({ config, onChange, onClose, onReset, monoColor
onClose()
}
function DeviceSelector({ config, onChange }) {
const [devices, setDevices] = useState([])
async function refresh() {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
const list = await navigator.mediaDevices.enumerateDevices()
setDevices(list.filter(d => d.kind === 'audioinput'))
} catch (e) {
console.warn('enumerateDevices failed', e)
}
}
useEffect(() => { refresh() }, [])
return (
<div className="space-y-3">
<div className="flex gap-3 items-center">
<select
value={config.audioDeviceId ?? ''}
onChange={e => onChange('audioDeviceId', e.target.value === '' ? null : e.target.value)}
className="appearance-none bg-surface border border-border hover:border-gray-500 focus:border-accent focus:outline-none rounded-lg pl-3 pr-7 py-1 text-sm text-gray-200 cursor-pointer transition-colors w-full"
>
<option value="">System default</option>
{devices.map((d, i) => (
<option key={d.deviceId || i} value={d.deviceId}>{d.label || `Microphone ${i + 1}`}</option>
))}
</select>
<button onClick={refresh} className="px-3 py-1 rounded-lg border border-border text-sm text-gray-400">Refresh</button>
</div>
<div className="text-xs text-gray-600">If device labels are empty, grant microphone permission first and hit Refresh.</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
@@ -175,6 +210,14 @@ export default function Settings({ config, onChange, onClose, onReset, monoColor
</div>
</div>
))}
{/* Audio device selector */}
<div>
<h3 className="text-xs uppercase tracking-widest text-gray-500 mb-4 border-b border-border pb-2">
Microphone
</h3>
<DeviceSelector config={config} onChange={onChange} />
</div>
</div>
</div>
+11 -1
View File
@@ -78,7 +78,17 @@ export function useAudioTuner() {
const startListening = async () => {
if (isListening) return
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
// Read saved device selection from config (if present)
let deviceId = null
try {
const cfg = JSON.parse(localStorage.getItem('wtf_config') || '{}')
deviceId = cfg.audioDeviceId || null
} catch (e) {
// ignore
}
const constraints = deviceId ? { audio: { deviceId: { exact: deviceId } } } : { audio: true }
console.log('Tuner requesting getUserMedia with', constraints)
const stream = await navigator.mediaDevices.getUserMedia(constraints)
const ctx = new (window.AudioContext || window.webkitAudioContext)()
const analyser = ctx.createAnalyser()
analyser.fftSize = 4096