diff --git a/CLAUDE.md b/CLAUDE.md index 25c58da..f73772e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Communication between renderer and main is **only** through `window.api`, which All IPC uses `invoke`/`handle` — never `send`/`receive`. Every operation is a typed Promise. ```typescript -// renderer — calls window.api directly, or via a Zustand store action +// renderer — calls store actions; stores call window.api const samples = await window.api.library.getSamples() // main (electron/main/ipc/*.ts) — registers the handler @@ -58,8 +58,12 @@ Adding a new IPC operation requires changes in three places: the handler in `ele Zustand stores live in `src/stores/`. They own async operations — components call store actions, not `window.api` directly. - `player` — current loaded audio source -- `library` — sample list, search, filters +- `library` — sample list, search, filters, save-chops orchestration - `packs` — pack being built, pad slots, hardware profile +- `projects` — active project, save/load +- `freesound` — search results, download state +- `ui` — view navigation +- `toast` — notification queue ### Database @@ -67,7 +71,7 @@ SQLite via `better-sqlite3` (synchronous). Initialised in `electron/main/db/inde ### Hardware profiles -Defined in `electron/main/hardware/profiles.ts` as plain config objects — adding a new device is adding one object to the array, no other changes needed. Each profile specifies container format, sample rate, bit depth, and a `fileName` function for pad naming conventions. +Defined in `electron/main/hardware/profiles.ts`. Adding a new device is adding one object to the array, no other changes needed. `applyProfileFormat(profile, cmd)` configures an ffmpeg command for the profile — IPC handlers call this instead of reading `profile.format.*` directly. ### Tailwind @@ -75,6 +79,6 @@ v4 with `@tailwindcss/vite`. No `tailwind.config.js` — theme extensions go in ## Current state -Phase 1 (foundation) is complete. The app loads a local audio file, renders a waveform, supports drag-to-create regions, and can save/export. YouTube integration was intentionally removed. +Phases 1 and 2 are complete. The full three-view workflow is working end-to-end: Chop (waveform editor with region creation), Library (SQLite-backed sample browser), and Packs (4×4 pad grid → hardware export). Freesound search and download are live. -Phase 2 builds the three-view UI: Chop (current waveform editor), Library (SQLite-backed sample browser), and Packs (4×4 pad grid → hardware export). Freesound API integration is also Phase 2. +Phase 3 (intelligence) is mostly complete: BPM detection, key detection, and transient-based auto-chop are all shipped. Remaining: BPM/key filter in the Library view, pitch shift on export, and time stretch on export. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 134d04f..22da8fb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -96,13 +96,13 @@ The library is backed by SQLite via `better-sqlite3`. The database file lives in ## State Management -Seven Zustand stores, each owning one domain: `player`, `library`, `packs`, `projects`, `freesound`, `ui`, and `toast`. Stores call `window.api.*` directly — no intermediate service layer. Components call store actions. +Seven Zustand stores, each owning one domain: `player`, `library`, `packs`, `projects`, `freesound`, `ui`, and `toast`. Stores own async operations — they call `window.api.*` and coordinate multi-step flows (e.g. save → analyze → persist). Components call store actions, not `window.api` directly. --- ## Hardware Profiles -Each profile is a plain config object in `electron/main/hardware/profiles.ts`. Adding a new hardware target requires no code changes beyond adding one entry to the array. Each profile specifies container format, sample rate, bit depth, and a `fileName` function for pad naming conventions. +Defined in `electron/main/hardware/profiles.ts`. Adding a new hardware target requires no code changes beyond adding one entry to the array. Each profile specifies container format, sample rate, bit depth, and a `fileName` function for pad naming conventions. `applyProfileFormat(profile, cmd)` configures an ffmpeg command for a given profile — callers never read `profile.format.*` directly. --- @@ -114,18 +114,17 @@ When the user exports a pack: Packs view → window.api.packs.export(packId, outputDir) → IPC: 'packs:export' - → main/services/export.ts + → electron/main/ipc/packs.ts 1. Load pack + slots from DB 2. Resolve hardware profile 3. For each slot: - ffmpeg trim source file to [start, end] - resample to profile.format.sampleRate - convert bit depth to profile.format.bitDepth - write to outputDir/profile.fileName(slot, name) - → returns { success: true, filesWritten: number } + applyProfileFormat(profile, ffmpeg(filePath)) + trims, resamples, and converts to profile format + writes to outputDir/profile.fileName(slot, name) + → returns { filesWritten: number } ``` -ffmpeg runs as a child process via `fluent-ffmpeg`. Each trim is a separate ffmpeg call. For 16 pads this is fast enough to run sequentially without a progress bar, but we can add one later. +ffmpeg runs as a child process via `fluent-ffmpeg`. Each slot is a separate ffmpeg call run in parallel via `Promise.all`. `applyProfileFormat` in `hardware/profiles.ts` owns the full ffmpeg format config — IPC handlers do not read `profile.format.*` directly. --- @@ -137,7 +136,9 @@ BPM and key detection run in the renderer using the Web Audio API and custom sig - **Key** — Krumhansl-Schmuckler pitch-class profiles compared against all 24 major/minor keys - **Transient detection** — adaptive threshold on onset strength with configurable sensitivity (coarse / medium / fine), used for auto-chop -Analysis runs once when audio is loaded into the Chop view. Results are stored on the sample record when saved to the library. +Analysis runs once when audio is loaded into the Chop view. `analyzeAudioUrl` caches results by URL — repeated calls for the same file return the same Promise. When chops are saved to the library, the `library.saveChops` store action fires analysis in the background and persists BPM + key to the sample record. + +WAV waveform peak extraction for the library view runs in the main process (`electron/main/audio/waveform.ts`) at save time, not in the renderer. --- @@ -160,5 +161,5 @@ Freesound has a public REST API with Creative Commons licensed audio. The API ke - **No comments explaining what code does.** Names should do that. Comments only for non-obvious _why_ — a constraint, a workaround, a subtle invariant. - **IPC handlers throw on error.** The invoke/handle pattern propagates errors as rejections. No separate error channels. - **Stores own async.** Components call store actions, not `window.api` directly. -- **Hardware profiles are data, not code.** A new device is a new object in the array, nothing else. +- **Hardware profiles are config, not logic.** A new device is a new object in the array. `applyProfileFormat` owns the ffmpeg config so handlers never reach into `profile.format.*`. - **Waveform peaks are pre-computed.** When a sample is added to the library, its waveform data is computed once and stored in the DB. The Library view renders instantly without re-reading audio files. diff --git a/electron/main/audio/waveform.ts b/electron/main/audio/waveform.ts new file mode 100644 index 0000000..bc27310 --- /dev/null +++ b/electron/main/audio/waveform.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs' + +// Reads a s16 stereo WAV (as produced by trimToWav) and returns ~100 peak amplitude values. +export function extractWaveformData(filePath: string, bars = 100): number[] { + const buf = fs.readFileSync(filePath) + + // Walk chunks to find 'data' + let offset = 12 + while (offset < buf.length - 8) { + const chunkId = buf.toString('ascii', offset, offset + 4) + const chunkSize = buf.readUInt32LE(offset + 4) + if (chunkId === 'data') { offset += 8; break } + offset += 8 + chunkSize + } + + const bytesPerFrame = 4 // s16 stereo + const totalFrames = Math.floor((buf.length - offset) / bytesPerFrame) + const framesPerBar = Math.max(1, Math.floor(totalFrames / bars)) + const result: number[] = [] + + for (let i = 0; i < bars; i++) { + let peak = 0 + const start = offset + i * framesPerBar * bytesPerFrame + const end = Math.min(start + framesPerBar * bytesPerFrame, buf.length - 1) + for (let j = start; j < end; j += 2) { + const v = Math.abs(buf.readInt16LE(j)) / 32767 + if (v > peak) peak = v + } + result.push(peak) + } + + return result +} diff --git a/electron/main/db/queries/samples.ts b/electron/main/db/queries/samples.ts index b87086d..3efa037 100644 --- a/electron/main/db/queries/samples.ts +++ b/electron/main/db/queries/samples.ts @@ -30,17 +30,36 @@ function deserialize(row: Record): Sample { export function getAllSamples(filters?: SampleFilters): Sample[] { const db = getDb() - const rows = db.prepare('SELECT * FROM samples ORDER BY created_at DESC').all() as Record[] - - return rows.map(deserialize).filter((sample) => { - if (!filters) return true - if (filters.bpm !== undefined && Math.abs((sample.bpm ?? 0) - filters.bpm) > 5) return false - if (filters.key && sample.musicalKey !== filters.key) return false - if (filters.tags?.length && !filters.tags.some((t) => sample.tags.includes(t))) return false - if (filters.source && sample.source !== filters.source) return false - if (filters.projectId !== undefined && sample.projectId !== filters.projectId) return false - return true - }) + + const conditions: string[] = [] + const values: unknown[] = [] + + if (filters?.bpm !== undefined) { + conditions.push('ABS(COALESCE(bpm, 0) - ?) <= 5') + values.push(filters.bpm) + } + if (filters?.key) { + conditions.push('musical_key = ?') + values.push(filters.key) + } + if (filters?.source) { + conditions.push('source = ?') + values.push(filters.source) + } + if (filters?.projectId !== undefined) { + conditions.push('project_id = ?') + values.push(filters.projectId) + } + + const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '' + const rows = db.prepare(`SELECT * FROM samples ${where} ORDER BY created_at DESC`).all(...values) as Record[] + const result = rows.map(deserialize) + + // Tags are JSON arrays in the column; filter in-memory to avoid json_each complexity + if (filters?.tags?.length) { + return result.filter((s) => filters.tags!.some((t) => s.tags.includes(t))) + } + return result } export function addSample(data: NewSample): Sample { diff --git a/electron/main/hardware/profiles.ts b/electron/main/hardware/profiles.ts index 99ee5d9..66ed726 100644 --- a/electron/main/hardware/profiles.ts +++ b/electron/main/hardware/profiles.ts @@ -1,3 +1,5 @@ +import type { FfmpegCommand } from 'fluent-ffmpeg' + export type HardwareProfile = { id: string name: string @@ -11,6 +13,14 @@ export type HardwareProfile = { fileName: (slot: number, sampleName: string) => string } +export function applyProfileFormat(profile: HardwareProfile, cmd: FfmpegCommand): FfmpegCommand { + return cmd + .toFormat(profile.format.container) + .audioFrequency(profile.format.sampleRate) + .audioChannels(2) + .outputOptions([`-sample_fmt ${profile.format.sampleFmt}`]) +} + export const profiles: HardwareProfile[] = [ { id: 'maschine-mk3', diff --git a/electron/main/ipc/audio.ts b/electron/main/ipc/audio.ts index 42a10f2..400f420 100644 --- a/electron/main/ipc/audio.ts +++ b/electron/main/ipc/audio.ts @@ -1,7 +1,7 @@ import { ipcMain } from 'electron' import path from 'node:path' import fs from 'node:fs' -import { getProfile } from '../hardware/profiles' +import { getProfile, applyProfileFormat } from '../hardware/profiles' import { trimSourceToCache } from '../services/trim' import { configureFfmpeg, ffmpeg } from '../services/ffmpeg' import type { ExportRegionsParams, TrimSourceParams } from '../../types' @@ -20,13 +20,9 @@ export function registerAudioHandlers(): void { new Promise((resolve, reject) => { const outputFile = path.join(outputDir, profile.fileName(index, region.name || `sample_${index + 1}`)) - ffmpeg(sourceFilePath) + applyProfileFormat(profile, ffmpeg(sourceFilePath) .setStartTime(region.start) - .setDuration(region.end - region.start) - .toFormat(profile.format.container) - .audioFrequency(profile.format.sampleRate) - .audioChannels(2) - .outputOptions([`-sample_fmt ${profile.format.sampleFmt}`]) + .setDuration(region.end - region.start)) .output(outputFile) .on('end', () => resolve()) .on('error', reject) diff --git a/electron/main/ipc/library.ts b/electron/main/ipc/library.ts index fff9b84..8f61abc 100644 --- a/electron/main/ipc/library.ts +++ b/electron/main/ipc/library.ts @@ -4,40 +4,9 @@ import fs from 'node:fs' import * as samples from '../db/queries/samples' import * as projects from '../db/queries/projects' import { trimToWav } from '../services/trim' +import { extractWaveformData } from '../audio/waveform' import type { Sample, Project, ProjectRegion } from '../../types' -// Reads a s16 stereo WAV (as produced by trimToWav) and returns ~100 peak amplitude values. -function extractWaveformData(filePath: string, bars = 100): number[] { - const buf = fs.readFileSync(filePath) - - // Walk chunks to find 'data' - let offset = 12 - while (offset < buf.length - 8) { - const chunkId = buf.toString('ascii', offset, offset + 4) - const chunkSize = buf.readUInt32LE(offset + 4) - if (chunkId === 'data') { offset += 8; break } - offset += 8 + chunkSize - } - - const bytesPerFrame = 4 // s16 stereo - const totalFrames = Math.floor((buf.length - offset) / bytesPerFrame) - const framesPerBar = Math.max(1, Math.floor(totalFrames / bars)) - const result: number[] = [] - - for (let i = 0; i < bars; i++) { - let peak = 0 - const start = offset + i * framesPerBar * bytesPerFrame - const end = Math.min(start + framesPerBar * bytesPerFrame, buf.length - 1) - for (let j = start; j < end; j += 2) { - const v = Math.abs(buf.readInt16LE(j)) / 32767 - if (v > peak) peak = v - } - result.push(peak) - } - - return result -} - export function registerLibraryHandlers(): void { ipcMain.handle('library:getSamples', (_, filters?: { bpm?: number; key?: string; tags?: string[]; projectId?: string }) => { return samples.getAllSamples(filters) diff --git a/electron/main/ipc/packs.ts b/electron/main/ipc/packs.ts index 6a5809c..3e4480e 100644 --- a/electron/main/ipc/packs.ts +++ b/electron/main/ipc/packs.ts @@ -1,8 +1,7 @@ import { ipcMain } from 'electron' import * as packsDb from '../db/queries/packs' import * as samplesDb from '../db/queries/samples' -import { getProfile } from '../hardware/profiles' -import { profiles } from '../hardware/profiles' +import { getProfile, applyProfileFormat, profiles } from '../hardware/profiles' import type { Pack } from '../../types' import path from 'node:path' import fs from 'node:fs' @@ -61,11 +60,7 @@ export function registerPacksHandlers(): void { const outputFile = path.join(outputDir, profile.fileName(slot.slotNumber, sample.name)) - ffmpeg(sample.filePath) - .toFormat(profile.format.container) - .audioFrequency(profile.format.sampleRate) - .audioChannels(2) - .outputOptions([`-sample_fmt ${profile.format.sampleFmt}`]) + applyProfileFormat(profile, ffmpeg(sample.filePath)) .output(outputFile) .on('end', () => resolve()) .on('error', reject) diff --git a/src/components/AudioWaveform.tsx b/src/components/AudioWaveform.tsx index 03efbd6..be42cf2 100644 --- a/src/components/AudioWaveform.tsx +++ b/src/components/AudioWaveform.tsx @@ -16,11 +16,11 @@ import { Input } from '@/components/ui/Input' import CardHeader from './Card/CardHeader' import SampleList from './SampleList' import TrimOverlay from './TrimOverlay' -import { analyzeAudioUrl, detectTransientsFromUrl } from '@/lib/audioAnalysis' +import { detectTransientsFromUrl } from '@/lib/audioAnalysis' import { remapRegionsForTrim } from '@/lib/remapRegions' import { cn } from '@/lib/utils' import { formatTime, toLocalFileUrl } from '@/utils' -import type { ProjectRegion, Sample } from '@/types' +import type { ProjectRegion } from '@/types' interface AudioWaveformProps { audioUrl: string @@ -56,7 +56,7 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio wavesurfer, initialRegions, }) - const { fetchSamples, updateSample } = useLibraryStore() + const { saveChops } = useLibraryStore() const [isSaving, setIsSaving] = useState(false) const [isExporting, setIsExporting] = useState(false) @@ -72,31 +72,20 @@ const AudioWaveform = ({ audioUrl, audioName, filePath, size, type, initialRegio [regions, regionNames] ) - const analyzeAndPersist = useCallback(async (saved: Sample[]) => { - for (const sample of saved) { - try { - const result = await analyzeAudioUrl(toLocalFileUrl(sample.filePath)) - await updateSample(sample.id, result) - } catch { /* non-fatal */ } - } - }, [updateSample]) - const handleSaveToLibrary = useCallback(async () => { if (!regions?.length) return setIsSaving(true) try { - const saved = await window.api.library.saveChops({ + await saveChops({ sourceFilePath: filePath, regions: regions.map((r) => ({ start: r.start, end: r.end, name: regionNames[r.id] ?? '' })), projectId: activeProject?.id, }) - await fetchSamples() toast(`${regions.length} chop${regions.length !== 1 ? 's' : ''} saved to Library`) - analyzeAndPersist(saved) } finally { setIsSaving(false) } - }, [filePath, regions, regionNames, fetchSamples, toast, analyzeAndPersist, activeProject?.id]) + }, [filePath, regions, regionNames, saveChops, toast, activeProject?.id]) const handleSaveProject = useCallback(async () => { if (!projectName.trim() || !regions?.length) return diff --git a/src/lib/audioAnalysis.ts b/src/lib/audioAnalysis.ts index 91aca22..3af3b54 100644 --- a/src/lib/audioAnalysis.ts +++ b/src/lib/audioAnalysis.ts @@ -143,16 +143,27 @@ export function analyzeAudioBuffer(buffer: AudioBuffer): { bpm: number; musicalK return { bpm, musicalKey } } -export async function analyzeAudioUrl(url: string): Promise<{ bpm: number; musicalKey: string }> { - const response = await fetch(url) - const arrayBuffer = await response.arrayBuffer() - const ctx = new AudioContext() - try { - const buffer = await ctx.decodeAudioData(arrayBuffer) - return analyzeAudioBuffer(buffer) - } finally { - ctx.close() - } +const analysisCache = new Map>() + +export function analyzeAudioUrl(url: string): Promise<{ bpm: number; musicalKey: string }> { + const cached = analysisCache.get(url) + if (cached) return cached + + const result = (async () => { + const response = await fetch(url) + const arrayBuffer = await response.arrayBuffer() + const ctx = new AudioContext() + try { + const buffer = await ctx.decodeAudioData(arrayBuffer) + return analyzeAudioBuffer(buffer) + } finally { + ctx.close() + } + })() + + analysisCache.set(url, result) + result.catch(() => analysisCache.delete(url)) + return result } // Onset strength is computed as positive first-order RMS energy differences. diff --git a/src/stores/freesound.ts b/src/stores/freesound.ts index c0fa199..124b07c 100644 --- a/src/stores/freesound.ts +++ b/src/stores/freesound.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { withLoading } from './utils' import type { FreesoundResult } from '@/types' type FreesoundState = { @@ -24,30 +25,31 @@ export const useFreesoundStore = create((set, get) => ({ isDownloading: [], search: async (query) => { - set({ query, isSearching: true, results: [], page: 1, hasMore: false }) - try { - const data = await window.api.freesound.search(query, 1) - set({ results: data.results, hasMore: data.next !== null, page: 1 }) - } finally { - set({ isSearching: false }) - } + set({ query, results: [], page: 1, hasMore: false }) + await withLoading( + (v) => set({ isSearching: v }), + async () => { + const data = await window.api.freesound.search(query, 1) + set({ results: data.results, hasMore: data.next !== null, page: 1 }) + } + ) }, loadMore: async () => { const { query, page, isSearching } = get() if (isSearching || !query) return const nextPage = page + 1 - set({ isSearching: true }) - try { - const data = await window.api.freesound.search(query, nextPage) - set((s) => ({ - results: [...s.results, ...data.results], - hasMore: data.next !== null, - page: nextPage, - })) - } finally { - set({ isSearching: false }) - } + await withLoading( + (v) => set({ isSearching: v }), + async () => { + const data = await window.api.freesound.search(query, nextPage) + set((s) => ({ + results: [...s.results, ...data.results], + hasMore: data.next !== null, + page: nextPage, + })) + } + ) }, startDownload: (id) => set((s) => ({ isDownloading: [...s.isDownloading, id] })), diff --git a/src/stores/library.ts b/src/stores/library.ts index 0291d09..4ad1fe1 100644 --- a/src/stores/library.ts +++ b/src/stores/library.ts @@ -1,4 +1,7 @@ import { create } from 'zustand' +import { analyzeAudioUrl } from '@/lib/audioAnalysis' +import { toLocalFileUrl } from '@/utils' +import { withLoading } from './utils' import type { Sample } from '../../electron/types' type Filters = { @@ -20,6 +23,7 @@ type LibraryState = { addSample: (data: { name: string; filePath: string; duration?: number }) => Promise updateSample: (id: string, data: Partial>) => Promise deleteSample: (id: string) => Promise + saveChops: (params: { sourceFilePath: string; regions: Array<{ start: number; end: number; name: string }>; projectId?: string }) => Promise setSearchQuery: (query: string) => void setFilters: (filters: Filters) => void setProjectFilter: (projectId: string | null) => void @@ -27,7 +31,7 @@ type LibraryState = { setSelectedSample: (sample: Sample | null) => void } -export const useLibraryStore = create((set) => ({ +export const useLibraryStore = create((set, get) => ({ samples: [], searchQuery: '', filters: {}, @@ -35,11 +39,13 @@ export const useLibraryStore = create((set) => ({ selectedSample: null, isLoading: false, - fetchSamples: async () => { - set({ isLoading: true }) - const samples = await window.api.library.getSamples() - set({ samples, isLoading: false }) - }, + fetchSamples: () => withLoading( + (v) => set({ isLoading: v }), + async () => { + const samples = await window.api.library.getSamples() + set({ samples }) + } + ), addSample: async (data) => { const sample = await window.api.library.addSample(data) @@ -62,6 +68,22 @@ export const useLibraryStore = create((set) => ({ })) }, + saveChops: async (params) => { + const saved = await window.api.library.saveChops(params) + const samples = await window.api.library.getSamples() + set({ samples }) + // Fire-and-forget: analyze each chop and persist BPM + key + ;(async () => { + const { updateSample } = get() + for (const sample of saved) { + try { + const result = await analyzeAudioUrl(toLocalFileUrl(sample.filePath)) + await updateSample(sample.id, result) + } catch { /* non-fatal */ } + } + })() + }, + setSearchQuery: (searchQuery) => set({ searchQuery }), setFilters: (filters) => set({ filters }), setProjectFilter: (projectFilter) => set({ projectFilter }), diff --git a/src/stores/projects.ts b/src/stores/projects.ts index 6d2cf59..d129b20 100644 --- a/src/stores/projects.ts +++ b/src/stores/projects.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { withLoading } from './utils' import type { Project } from '@/types' interface ProjectsState { @@ -23,15 +24,13 @@ export const useProjectsStore = create((set, get) => ({ isProjectDirty: false, isLoading: false, - fetchProjects: async () => { - set({ isLoading: true }) - try { + fetchProjects: () => withLoading( + (v) => set({ isLoading: v }), + async () => { const projects = await window.api.projects.getAll() set({ projects }) - } finally { - set({ isLoading: false }) } - }, + ), setActiveProject: (project) => set({ activeProject: project, isProjectDirty: false }), diff --git a/src/stores/utils.ts b/src/stores/utils.ts new file mode 100644 index 0000000..35196b4 --- /dev/null +++ b/src/stores/utils.ts @@ -0,0 +1,11 @@ +export async function withLoading( + setLoading: (v: boolean) => void, + action: () => Promise +): Promise { + setLoading(true) + try { + return await action() + } finally { + setLoading(false) + } +}