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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ jobs:
- run: pnpm tsc
- run: pnpm lint
- run: pnpm test
# Build the renderer so a broken import/asset fails CI instead of shipping to a release tag
# (F37). electron-builder is skipped here — it only runs in the release workflow.
- run: pnpm exec vite build
- run: pnpm audit --audit-level=high --prod
744 changes: 744 additions & 0 deletions .harness/qa/adversarial-audit-2026-07-03.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Freesound API key:** Set inside the app's Settings UI (not a `.env` file). Stored at `$USERDATA/settings.json` (`app.getPath('userData')`). Only needed to use Freesound search inside the app — not required for seeding.

**Seed script:** Run `pnpm dev` at least once first (creates the SQLite database), then `pnpm seed`. Audio files are bundled in `scripts/seed-audio/` — no API key or network access needed. userData is always at `~/Library/Application Support/samplebyte/` in both dev and production builds (prior to the `app.setName()` fix, dev mode used `~/Library/Application Support/Electron/` instead).
**Seed script:** Run `pnpm dev` at least once first (creates the SQLite database), then `pnpm seed`. Audio files are bundled in `scripts/seed-audio/` — no API key or network access needed. On macOS userData is at `~/Library/Application Support/samplebyte/` in both dev and production (prior to the `app.setName()` fix, dev mode used `~/Library/Application Support/Electron/`); on Linux it's `~/.config/samplebyte/` and on Windows `%APPDATA%\samplebyte\`. The seed script checks all three and only clears its own `seed-`prefixed rows, so it won't wipe your dev library.

**Stem separation model:** The Chop tab's Stems tool needs the vendored demucs WASM (`demucs.js` / `.wasm` / `.data`, ~85MB, MIT, originally from `uzstudio/free-music-demixer`). Run `pnpm fetch:stem-model` to download it into `public/stem-model/` (gitignored). The files are mirrored as assets on our own `stem-model-v1` GitHub release so the build does not depend on the upstream repo staying online; if those binaries ever need replacing, upload new assets there and update `BASE` in `scripts/fetch-stem-model.mjs`. Required before `pnpm build` if you want the feature in the packaged app; without it the tool reports a clear "run pnpm fetch:stem-model" error and the rest of the app is unaffected. The model is the 4-source variant (Drums/Bass/Other/Vocals) and runs single-threaded in a renderer web worker (slow: ~6.6× realtime, cached per source). Packaged CSP allows `'unsafe-eval'` because the Emscripten build is loaded from text and instantiates WebAssembly.

Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,19 @@ This is not a commercial product. It's open source, free to use, and built in my
|---|---|---|---|
| Maschine MK3 | WAV | 44.1 kHz | 16-bit |
| Roland SP-404 MkII | WAV | 48 kHz | 16-bit |
| Akai MPC (generic) | WAV | 44.1 kHz | 24-bit |
| Akai MPC One | WAV | 44.1 kHz | 24-bit |
| Generic WAV | WAV | 44.1 kHz | 24-bit |

Every profile exports up to 16 pads today.

Adding a new hardware target is just one config object in `electron/main/hardware/profiles.ts`, no other changes needed.

---

## Audio Sources

- **Local files:** drag and drop any audio file (WAV, MP3, FLAC, AIFF, OGG)
- **Freesound:** search 650,000+ Creative Commons sounds directly in the app — category shortcut chips (Kick, Snare, Hi-Hat, 808, etc.) on the empty state let you jump in fast, and sort/duration pill filters re-run the query instantly
- **Freesound:** search 650,000+ Creative Commons sounds directly in the app — category shortcut chips (Kick, Snare, Hi-Hat, 808, etc.) on the empty state let you jump in fast, and sort/duration pill filters re-run the query instantly. Loading a result opens the high-quality **preview** (~128 kbps MP3) in Chop; original-quality downloads (which need OAuth) are a planned follow-up. License and author are stored with any chops you keep and written to a `credits.txt` when you export a pack, so you can honour CC attribution.

No YouTube. Not because it isn't useful (the original version of this app was built around it), but building on top of bypassing another platform's ToS is a dead end. Freesound covers discovery legally and with solid content. Local files cover everything else.

Expand Down Expand Up @@ -108,6 +110,23 @@ Click "More info" then "Run anyway" to get past the unsigned-app warning.
| `⌘Z` / `⇧⌘Z` | Undo / Redo chop edits |
| `Mouse wheel` | Zoom waveform |

On Windows and Linux, use `Ctrl` in place of `⌘` (the app shows the right label per platform).

---

## Building from source

Requires the current Node.js LTS and [pnpm](https://pnpm.io/). A native toolchain is needed for the `postinstall` step, which rebuilds `better-sqlite3` against Electron's ABI ([node-gyp prerequisites](https://github.com/nodejs/node-gyp#installation)).

```bash
pnpm install # installs deps and runs electron-rebuild
pnpm dev # launch the app (creates the SQLite DB on first run)
pnpm test # vitest (audio-rendering suites run against real ffmpeg)
pnpm tsc && pnpm lint # typecheck + lint
```

The Chop tab's Stems tool needs a vendored model that is not in git; run `pnpm fetch:stem-model` once before `pnpm build` if you want that feature in a packaged build. `pnpm seed` loads demo projects/packs (run `pnpm dev` at least once first). See [AGENTS.md](AGENTS.md) for deeper contributor notes.

---

## Acknowledgments
Expand Down
38 changes: 29 additions & 9 deletions electron/ipc-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type {
ProjectRegion,
ProjectChop,
PackSourceItem,
ExportRegionsParams,
FreesoundPage,
StemPcm,
StemFile,
Expand All @@ -23,11 +22,6 @@ export type Api = {
addSample: (data: { name: string; filePath: string; duration?: number }) => Promise<Sample>
updateSample: (id: string, data: Partial<Pick<Sample, 'name' | 'bpm' | 'musicalKey' | 'tags' | 'waveformData'>>) => Promise<void>
deleteSample: (id: string) => Promise<void>
saveChops: (params: {
sourceFilePath: string
regions: Array<{ start: number; end: number; name: string }>
projectId?: string
}) => Promise<Sample[]>
importFolder: (folderPath: string) => Promise<{ imported: number; skipped: number }>
getPackSlotRefCount: (id: string) => Promise<number>
getOrphans: () => Promise<Sample[]>
Expand All @@ -36,7 +30,7 @@ export type Api = {
projects: {
getAll: () => Promise<Project[]>
get: (id: string) => Promise<Project | null>
save: (data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; regions: ProjectRegion[] }) => Promise<Project>
save: (data: { name: string; sourcePath: string | null; sourceName?: string | null; source?: 'local' | 'freesound'; freesoundId?: string | null; license?: string | null; author?: string | null; regions: ProjectRegion[] }) => Promise<Project>
update: (id: string, data: Partial<Pick<Project, 'name' | 'sourcePath' | 'regions'>>) => Promise<void>
getChops: (projectId: string) => Promise<ProjectChop[]>
getAllChops: () => Promise<Array<ProjectChop & { projectName: string; sourcePath: string | null; source: 'local' | 'freesound' }>>
Expand All @@ -45,7 +39,6 @@ export type Api = {
duplicate: (id: string) => Promise<Project | null>
}
audio: {
exportRegions: (params: ExportRegionsParams) => Promise<{ filesWritten: number }>
trimSource: (params: { sourceFilePath: string; start: number; end: number }) => Promise<{ filePath: string; duration: number }>
}
stems: {
Expand All @@ -58,6 +51,8 @@ export type Api = {
}
fs: {
getPathForFile: (file: File) => string
// Broker a renderer-obtained path (drag-drop) so local-file:// will serve it. See F32.
allowPath: (filePath: string) => Promise<void>
pickFile: () => Promise<string | null>
pickFolder: () => Promise<string | null>
}
Expand All @@ -81,11 +76,36 @@ export type Api = {
removeSlot: (packId: string, slotNumber: number) => Promise<void>
rename: (id: string, name: string) => Promise<void>
delete: (id: string) => Promise<void>
export: (packId: string, outputDir: string) => Promise<{ filesWritten: number }>
export: (packId: string, outputDir: string) => Promise<{ filesWritten: number; failed: number }>
// Rebuild an orphaned chop pad's audio back into the library (from the pad's owned WAV) and
// relink the pad to the new sample. Returns the created sample.
regenerateSlotToLibrary: (packId: string, slotNumber: number) => Promise<Sample>
}
updates: {
check: () => Promise<UpdateCheckResult>
download: () => Promise<void>
install: () => Promise<void>
}
}

// Result of an update check. `available` is false in dev (nothing to update); `error` carries a
// network/check failure message.
export type UpdateCheckResult =
| { available: boolean; version: string; newVersion?: string }
| { error: string }

// Main -> renderer push events. Kept separate from the invoke groups above so it stays out of the
// ApiChannels flattening (these are ipcRenderer.on subscriptions, not invoke handlers). Each method
// registers a listener and returns an unsubscribe function.
export type ApiEvents = {
// Fired after a background startup task (chop backfill) changes the library so the UI can refetch.
onLibraryChanged: (cb: () => void) => () => void
// Auto-update lifecycle (F18): a newer version is available, download progress (0..100), the
// download finished (ready to install), or the updater errored.
onUpdateAvailable: (cb: (info: { version: string; newVersion?: string }) => void) => () => void
onUpdateProgress: (cb: (percent: number) => void) => () => void
onUpdateDownloaded: (cb: () => void) => () => void
onUpdateError: (cb: (message: string) => void) => () => void
}

type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never
Expand Down
50 changes: 50 additions & 0 deletions electron/main/audio/waveform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import os from 'node:os'
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { renderClip, LIBRARY_FORMAT } from '../services/render'
import { extractWaveformData, readWavDuration } from './waveform'
import { readWavInfo } from '../../../test/wav'

const FIXTURE = fileURLToPath(new URL('../../../scripts/seed-audio/amen-break.mp3', import.meta.url))

let tmpDir: string
let clip: string
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waveform-test-'))
clip = path.join(tmpDir, 'clip.wav')
await renderClip(FIXTURE, { start: 0.25, end: 0.75 }, clip, LIBRARY_FORMAT)
})
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})

describe('readWavDuration', () => {
it('matches the real decoded length from the header (F9)', () => {
const duration = readWavDuration(clip)
expect(duration).not.toBeNull()
// Agrees with the independent test parser rather than the requested trim span.
expect(duration!).toBeCloseTo(readWavInfo(clip).duration, 3)
})

it('returns null for a non-WAV file', () => {
const junk = path.join(tmpDir, 'junk.bin')
fs.writeFileSync(junk, Buffer.from('not a wav file at all'))
expect(readWavDuration(junk)).toBeNull()
})
})

describe('extractWaveformData', () => {
it('returns the requested number of bars for a real clip', () => {
const bars = extractWaveformData(clip, 100)
expect(bars).toHaveLength(100)
expect(bars.some((b) => b > 0)).toBe(true)
})

it('returns zeros (not garbage) when there is no data chunk (F10)', () => {
const junk = path.join(tmpDir, 'nodata.bin')
fs.writeFileSync(junk, Buffer.from('garbage bytes with no riff header'))
expect(extractWaveformData(junk, 8)).toEqual(new Array(8).fill(0))
})
})
55 changes: 45 additions & 10 deletions electron/main/audio/waveform.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,52 @@
import fs from 'node:fs'

// Reads a s16 stereo WAV (as produced by renderClip in LIBRARY_FORMAT) and returns ~100 peak amplitude values.
export function extractWaveformData(filePath: string, bars = 100): number[] {
const buf = fs.readFileSync(filePath)
type WavChunk = { dataOffset: number; dataSize: number; sampleRate: number; channels: number; bitsPerSample: number }

// Walk chunks to find 'data'
// Walk the RIFF chunk list, honouring the format's word-alignment (odd-size chunks are padded to an
// even boundary) so a stray odd chunk before 'data' can't desync the parser. Returns null when the
// file has no readable fmt/data pair rather than treating trailing garbage as samples (F10).
function parseWav(buf: Buffer): WavChunk | null {
if (buf.length < 12 || buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') return null

let sampleRate = 0
let channels = 0
let bitsPerSample = 0
let offset = 12
while (offset < buf.length - 8) {
while (offset + 8 <= buf.length) {
const chunkId = buf.toString('ascii', offset, offset + 4)
const chunkSize = buf.readUInt32LE(offset + 4)
if (chunkId === 'data') { offset += 8; break }
offset += 8 + chunkSize
const body = offset + 8
if (chunkId === 'fmt ' && body + 16 <= buf.length) {
channels = buf.readUInt16LE(body + 2)
sampleRate = buf.readUInt32LE(body + 4)
bitsPerSample = buf.readUInt16LE(body + 14)
} else if (chunkId === 'data') {
const dataSize = Math.min(chunkSize, buf.length - body)
if (!sampleRate || !channels || !bitsPerSample) return null
return { dataOffset: body, dataSize, sampleRate, channels, bitsPerSample }
}
// Chunks are word-aligned: an odd size is followed by a single pad byte.
offset = body + chunkSize + (chunkSize & 1)
}
return null
}

// Reads a s16 stereo WAV (as produced by renderClip in LIBRARY_FORMAT) and returns ~100 peak amplitude values.
export function extractWaveformData(filePath: string, bars = 100): number[] {
const buf = fs.readFileSync(filePath)
const wav = parseWav(buf)
if (!wav) return new Array(bars).fill(0)

const bytesPerFrame = 4 // s16 stereo
const totalFrames = Math.floor((buf.length - offset) / bytesPerFrame)
const dataEnd = wav.dataOffset + wav.dataSize
const totalFrames = Math.floor(wav.dataSize / 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)
const start = wav.dataOffset + i * framesPerBar * bytesPerFrame
const end = Math.min(start + framesPerBar * bytesPerFrame, dataEnd - 1)
for (let j = start; j < end; j += 2) {
const v = Math.abs(buf.readInt16LE(j)) / 32767
if (v > peak) peak = v
Expand All @@ -31,3 +56,13 @@ export function extractWaveformData(filePath: string, bars = 100): number[] {

return result
}

// Real decoded duration in seconds from the WAV header, so library samples record what was actually
// written rather than the requested trim span (F9). Returns null for an unreadable file.
export function readWavDuration(filePath: string): number | null {
const wav = parseWav(fs.readFileSync(filePath))
if (!wav) return null
const bytesPerFrame = wav.channels * (wav.bitsPerSample / 8)
if (bytesPerFrame <= 0) return null
return wav.dataSize / bytesPerFrame / wav.sampleRate
}
36 changes: 36 additions & 0 deletions electron/main/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,50 @@ function runMigrations(): void {
if (!sampleCols.includes('source_chop_id')) {
db.exec('ALTER TABLE samples ADD COLUMN source_chop_id TEXT')
}
// File ownership: 1 only for files the app rendered/copied into userData/samples, which deleting
// the row is allowed to unlink. Import-in-place originals and shared stem-cache files stay 0 so
// deleteSample never destroys a user's own file (F1/T1). Backfill by path prefix: everything the
// app has ever exclusively owned lives under userData/samples.
if (!sampleCols.includes('owned')) {
db.exec('ALTER TABLE samples ADD COLUMN owned INTEGER NOT NULL DEFAULT 0')
const samplesDir = path.join(app.getPath('userData'), 'samples') + path.sep
db.prepare('UPDATE samples SET owned = 1 WHERE file_path LIKE ?').run(`${samplesDir}%`)
}
// Freesound attribution carried onto materialized samples so exported packs can credit CC sources (F24).
if (!sampleCols.includes('license')) db.exec('ALTER TABLE samples ADD COLUMN license TEXT')
if (!sampleCols.includes('author')) db.exec('ALTER TABLE samples ADD COLUMN author TEXT')
// Null out references to projects that no longer exist. project_id is a soft link with no FK,
// so deleted projects (and seed artifacts) leave samples pointing at ghost ids; the library
// reads these as "—". Project deletion now nulls these itself, so this is a one-time cleanup.
db.exec('UPDATE samples SET project_id = NULL WHERE project_id IS NOT NULL AND project_id NOT IN (SELECT id FROM projects)')

// Mark chops whose source can't be materialized (missing/unreadable file) so the one-time backfill
// stops retrying them on every launch forever (F14).
const chopCols = (db.prepare('PRAGMA table_info(project_chops)').all() as { name: string }[]).map((c) => c.name)
if (!chopCols.includes('materialize_failed')) {
db.exec('ALTER TABLE project_chops ADD COLUMN materialize_failed INTEGER NOT NULL DEFAULT 0')
}

const projectCols = (db.prepare('PRAGMA table_info(projects)').all() as { name: string }[]).map((c) => c.name)
if (!projectCols.includes('source_name')) {
db.exec('ALTER TABLE projects ADD COLUMN source_name TEXT')
}
if (!projectCols.includes('source')) {
db.exec("ALTER TABLE projects ADD COLUMN source TEXT NOT NULL DEFAULT 'local'")
}
// Freesound attribution for the project source (F24).
if (!projectCols.includes('freesound_id')) db.exec('ALTER TABLE projects ADD COLUMN freesound_id TEXT')
if (!projectCols.includes('license')) db.exec('ALTER TABLE projects ADD COLUMN license TEXT')
if (!projectCols.includes('author')) db.exec('ALTER TABLE projects ADD COLUMN author TEXT')

// Collapse any duplicate chop-sample rows left by the pre-serialization sync race (F4): keep the
// most recent row per source_chop_id and drop the rest. Their now-orphaned WAVs are reclaimed by
// the startup GC sweep. Must run before the UNIQUE index below can be created.
db.exec(`
DELETE FROM samples
WHERE source_chop_id IS NOT NULL
AND rowid NOT IN (SELECT MAX(rowid) FROM samples WHERE source_chop_id IS NOT NULL GROUP BY source_chop_id)
`)

migrateProjectRegionsToChops()
migratePackSlotsToSnapshots()
Expand All @@ -111,6 +143,10 @@ function runMigrations(): void {
CREATE INDEX IF NOT EXISTS idx_pack_slots_sample_id ON pack_slots(sample_id);
CREATE INDEX IF NOT EXISTS idx_samples_project_id ON samples(project_id);
CREATE INDEX IF NOT EXISTS idx_samples_source_chop_id ON samples(source_chop_id);
-- One materialized sample per source chop. Partial so the many source_chop_id=NULL rows
-- (imported/freesound/local samples) are unconstrained; enforces the projection invariant that
-- serialization already upholds (F4).
CREATE UNIQUE INDEX IF NOT EXISTS idx_samples_source_chop_id_unique ON samples(source_chop_id) WHERE source_chop_id IS NOT NULL;
`)
}

Expand Down
8 changes: 1 addition & 7 deletions electron/main/db/queries/packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ function deserializeSlot(row: Record<string, unknown>): PackSlot {
end: row.end as number | null,
displayName: row.display_name as string,
sourceChopUpdatedAt: row.source_chop_updated_at as number | null,
pitchShiftSemitones: row.pitch_shift_semitones as number | null,
timeStretchRatio: row.time_stretch_ratio as number | null,
audioPath: row.audio_path as string | null,
}
}
Expand Down Expand Up @@ -92,10 +90,8 @@ export function upsertSlot(packId: string, slotNumber: number, source: PackSourc
end,
display_name,
source_chop_updated_at,
pitch_shift_semitones,
time_stretch_ratio,
audio_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
.run(
packId,
Expand All @@ -109,8 +105,6 @@ export function upsertSlot(packId: string, slotNumber: number, source: PackSourc
source.end,
source.displayName,
source.sourceChopUpdatedAt,
null,
null,
audioPath
)
}
Expand Down
Loading