Merge branch 'main' of https://github.com/whattheflat/whattheflat
This commit is contained in:
@@ -9,33 +9,69 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build:
|
||||||
runs-on: windows-latest
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
strategy:
|
||||||
- uses: actions/checkout@v4
|
fail-fast: false # Ensures one OS failing doesn't kill the others
|
||||||
- uses: actions/setup-node@v4
|
matrix:
|
||||||
with:
|
include:
|
||||||
node-version: 20
|
- os: windows-latest
|
||||||
- run: npm install
|
command: npm run electron:build:win
|
||||||
- run: npm run electron:build:win
|
artifact_pattern: "release/*.exe"
|
||||||
env:
|
- os: macos-latest
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
# Added --universal here if you want to support both Intel and Apple Silicon
|
||||||
WIN_CSC_LINK: ''
|
command: npm run electron:build:mac -- --universal
|
||||||
- uses: softprops/action-gh-release@v2
|
artifact_pattern: "release/*.dmg"
|
||||||
with:
|
- os: ubuntu-latest
|
||||||
files: releases/*.exe
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- run: npm install
|
cache: 'npm'
|
||||||
- run: npm run electron:build:mac
|
|
||||||
env:
|
- name: Install dependencies
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
run: npm install
|
||||||
- uses: softprops/action-gh-release@v2
|
|
||||||
|
- name: Build Application
|
||||||
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
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 }}
|
||||||
@@ -70,6 +70,42 @@ npm run electron:build:linux
|
|||||||
|
|
||||||
Output is placed in `frontend/release/`.
|
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
|
## Design Tokens
|
||||||
|
|
||||||
All colors are defined in `frontend/tailwind.config.js` and can be referenced by name in any component.
|
All colors are defined in `frontend/tailwind.config.js` and can be referenced by name in any component.
|
||||||
|
|||||||
Generated
+68
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "whattheflat",
|
"name": "jambuddy",
|
||||||
"version": "0.1.0",
|
"version": "0.6.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "whattheflat",
|
"name": "jambuddy",
|
||||||
"version": "0.1.0",
|
"version": "0.6.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"audiomotion-analyzer": "^4.5.4",
|
"audiomotion-analyzer": "^4.5.4",
|
||||||
"pitchy": "^4.1.0",
|
"pitchy": "^4.1.0",
|
||||||
@@ -2189,6 +2189,70 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "whattheflat",
|
"name": "jambuddy",
|
||||||
"description": "A jam session companion app that provides a chromatic tuner, chord progressions, and more.",
|
"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,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"electron:build": "vite build && electron-builder",
|
"electron:build": "vite build && electron-builder",
|
||||||
"electron:build:win": "vite build && electron-builder --win --publish never",
|
"electron:build:win": "vite build && electron-builder --win --publish never",
|
||||||
"electron:build:mac": "vite build && electron-builder --mac --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": {
|
"dependencies": {
|
||||||
"audiomotion-analyzer": "^4.5.4",
|
"audiomotion-analyzer": "^4.5.4",
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
"wait-on": "^9.0.4"
|
"wait-on": "^9.0.4"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.whattheflat.app",
|
"appId": "com.jambuddy.app",
|
||||||
"productName": "WhatTheFlat",
|
"productName": "JamBuddy",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"electron/**/*",
|
"electron/**/*",
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const DEFAULTS = {
|
|||||||
// Audio input
|
// Audio input
|
||||||
minClarity: 0.80,
|
minClarity: 0.80,
|
||||||
minVolume: 0.01,
|
minVolume: 0.01,
|
||||||
|
// Selected device (null = system default)
|
||||||
|
audioDeviceId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStored(key, fallback) {
|
function loadStored(key, fallback) {
|
||||||
@@ -514,6 +516,7 @@ export default function App() {
|
|||||||
isListening={isListening}
|
isListening={isListening}
|
||||||
minClarity={config.minClarity}
|
minClarity={config.minClarity}
|
||||||
minVolume={config.minVolume}
|
minVolume={config.minVolume}
|
||||||
|
audioDeviceId={config.audioDeviceId}
|
||||||
onPermissionError={() => {
|
onPermissionError={() => {
|
||||||
setMicError(true)
|
setMicError(true)
|
||||||
setIsListening(false)
|
setIsListening(false)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
|||||||
return ((bestMidi % 12) + 12) % 12
|
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 audioCtxRef = useRef(null)
|
||||||
const timeBufRef = useRef(null)
|
const timeBufRef = useRef(null)
|
||||||
const freqBufRef = useRef(null)
|
const freqBufRef = useRef(null)
|
||||||
@@ -121,8 +121,34 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
|||||||
stop()
|
stop()
|
||||||
let stream
|
let stream
|
||||||
try {
|
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) {
|
} 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)
|
onPermissionErrorRef.current?.(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -135,6 +161,14 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
|||||||
audioCtxRef.current = ctx
|
audioCtxRef.current = ctx
|
||||||
const source = ctx.createMediaStreamSource(stream)
|
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
|
// Small analyser — pitch detection needs fast time-domain data
|
||||||
const pa = ctx.createAnalyser()
|
const pa = ctx.createAnalyser()
|
||||||
pa.fftSize = PITCH_FFT
|
pa.fftSize = PITCH_FFT
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react'
|
||||||
|
|
||||||
const SETTINGS = [
|
const SETTINGS = [
|
||||||
{
|
{
|
||||||
section: 'Chord Detection',
|
section: 'Chord Detection',
|
||||||
@@ -70,12 +72,10 @@ const SETTINGS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
import { useRef } from 'react'
|
|
||||||
|
|
||||||
export default function Settings({ config, onChange, onClose, onReset, monoColor, onMonoColorChange }) {
|
export default function Settings({ config, onChange, onClose, onReset, monoColor, onMonoColorChange }) {
|
||||||
// Snapshot on mount so Cancel can restore
|
// Snapshot on mount so Cancel can restore
|
||||||
const savedConfig = useRef(config)
|
const savedConfig = useRef(config)
|
||||||
const savedMono = useRef(monoColor)
|
const savedMono = useRef(monoColor)
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
Object.entries(savedConfig.current).forEach(([k, v]) => onChange(k, v))
|
Object.entries(savedConfig.current).forEach(([k, v]) => onChange(k, v))
|
||||||
@@ -83,6 +83,41 @@ export default function Settings({ config, onChange, onClose, onReset, monoColor
|
|||||||
onClose()
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
|
<div className="fixed inset-0 bg-surface z-50 overflow-y-auto">
|
||||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
<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>
|
||||||
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,7 +78,17 @@ export function useAudioTuner() {
|
|||||||
const startListening = async () => {
|
const startListening = async () => {
|
||||||
if (isListening) return
|
if (isListening) return
|
||||||
try {
|
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 ctx = new (window.AudioContext || window.webkitAudioContext)()
|
||||||
const analyser = ctx.createAnalyser()
|
const analyser = ctx.createAnalyser()
|
||||||
analyser.fftSize = 4096
|
analyser.fftSize = 4096
|
||||||
|
|||||||
Reference in New Issue
Block a user