Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,23 +58,27 @@ 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

SQLite via `better-sqlite3` (synchronous). Initialised in `electron/main/db/index.ts` before IPC handlers are registered. Queries are in `electron/main/db/queries/` — one file per table. Shared TypeScript types for DB entities live in `electron/types.ts` and are imported by both main and renderer.

### 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

v4 with `@tailwindcss/vite`. No `tailwind.config.js` — theme extensions go in the `@theme {}` block in `src/index.css`. No CSS reset (preflight disabled — only `theme.css` and `utilities.css` are imported).

## 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.
23 changes: 12 additions & 11 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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.

---

Expand All @@ -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.

---

Expand All @@ -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.
33 changes: 33 additions & 0 deletions electron/main/audio/waveform.ts
Original file line number Diff line number Diff line change
@@ -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
}
41 changes: 30 additions & 11 deletions electron/main/db/queries/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,36 @@ function deserialize(row: Record<string, unknown>): 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<string, unknown>[]

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<string, unknown>[]
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 {
Expand Down
10 changes: 10 additions & 0 deletions electron/main/hardware/profiles.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { FfmpegCommand } from 'fluent-ffmpeg'

export type HardwareProfile = {
id: string
name: string
Expand All @@ -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',
Expand Down
10 changes: 3 additions & 7 deletions electron/main/ipc/audio.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -20,13 +20,9 @@ export function registerAudioHandlers(): void {
new Promise<void>((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)
Expand Down
33 changes: 1 addition & 32 deletions electron/main/ipc/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 2 additions & 7 deletions electron/main/ipc/packs.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
21 changes: 5 additions & 16 deletions src/components/AudioWaveform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
31 changes: 21 additions & 10 deletions src/lib/audioAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Promise<{ bpm: number; musicalKey: string }>>()

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.
Expand Down
Loading