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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,9 @@ xattr -cr "MPC Sample.app"
```

Run either command once; no further steps are needed.

---

## Disclaimer

_MPC Sample.app is an independent, community-built tool and is not affiliated with, endorsed by, or sponsored by inMusic Brands, Inc. or its subsidiaries. "MPC" and "Akai Professional" are registered trademarks of inMusic Brands, Inc. All trademarks are the property of their respective owners._
19 changes: 19 additions & 0 deletions src/audio/SampleEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ export class SampleEngine implements AudioEngineLike {
}
}

setPadTrim(idx: PadIndex, startFrames: number, endFrames: number): void {
const existing = this.padMap.get(idx);
if (!existing) return;
this.padMap.set(idx, { ...existing, sampleStart: startFrames, sampleEnd: endFrames });
}

isPadLoading(idx: PadIndex): boolean {
return this.loading.has(idx);
}
Expand Down Expand Up @@ -466,6 +472,19 @@ export class SampleEngine implements AudioEngineLike {
}

const playTime = time ?? Tone.now();

const { sampleStart, sampleEnd } = pad;
if (sampleStart !== undefined && sampleEnd !== undefined) {
const audioBuffer = buf.get();
if (audioBuffer && audioBuffer.sampleRate > 0) {
const sr = audioBuffer.sampleRate;
const offsetSec = sampleStart / sr;
const durationSec = Math.max(0, (sampleEnd - sampleStart)) / sr;
pp.player.start(playTime, offsetSec, durationSec);
return;
}
}

pp.player.start(playTime);
}
}
16 changes: 1 addition & 15 deletions src/components/Knob.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useRef } from "react";
import "../styles/controls.css";
import { useMPCStore } from "../state/store";
import type { KnobName } from "../types/mpc.types";
Expand Down Expand Up @@ -63,20 +63,6 @@ export function Knob({ name, label, size = "md", variant = "silver" }: KnobProps
dragStartY.current = null;
}, []);

// Wheel event needs passive: false, so attach imperatively
useEffect(() => {
const el = capRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY * -0.001;
const cur = useMPCStore.getState().knobs[name];
const next = isContinuous ? cur + delta : clamp01(cur + delta);
setKnob(name, next);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [isContinuous, name, setKnob]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/MPCDevice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ export function MPCDevice({ engine = null }: MPCDeviceProps) {
{/* CENTER COLUMN: knobs + bank selector + pad grid */}
<div className="col-center">
<div className="knob-row">
<Knob name="k1" label="K1" />
<Knob name="k2" label="K2" />
<Knob name="k1" label="START" />
<Knob name="k2" label="END" />
<Knob name="k3" label="K3" />
</div>
<BankSelector />
Expand Down
59 changes: 58 additions & 1 deletion src/components/Screen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BANK_LABELS } from "../data/padLayout";
import { useMPCStore } from "../state/store";
import type { AudioEngineLike } from "../types/mpc.types";
Expand All @@ -13,6 +14,18 @@ export function Screen({ engine }: ScreenProps) {
const activeKit = useMPCStore((s) => s.activeKit);
const lastTriggeredPad = useMPCStore((s) => s.lastTriggeredPad);
const padMap = useMPCStore((s) => s.padMap);
const k1 = useMPCStore((s) => s.knobs.k1);
const k2 = useMPCStore((s) => s.knobs.k2);
const setPadTrim = useMPCStore((s) => s.setPadTrim);
const setKnob = useMPCStore((s) => s.setKnob);
const _loadingPads = useMPCStore((s) => s.loadingPads);

const [viewStart, setViewStart] = useState(0);
const [viewEnd, setViewEnd] = useState(1);
const handleViewChange = useCallback((start: number, end: number) => {
setViewStart(start);
setViewEnd(end);
}, []);

const padBankIdx = lastTriggeredPad !== null ? lastTriggeredPad >> 4 : bankIdx;
const padLocalNum = lastTriggeredPad !== null ? (lastTriggeredPad & 0xf) + 1 : 1;
Expand All @@ -22,6 +35,45 @@ export function Screen({ engine }: ScreenProps) {
const triggeredPad = lastTriggeredPad !== null ? padMap[lastTriggeredPad] : null;
const sampleName = triggeredPad ? triggeredPad.sampleName.replace(/\.wav$/i, "") : "—";

// Frame count for the currently displayed pad's buffer
const frameCount = useMemo(() => {
void _loadingPads;
if (!engine || lastTriggeredPad === null) return 0;
return engine.getPadChannelData?.(lastTriggeredPad)?.length ?? 0;
}, [engine, lastTriggeredPad, _loadingPads]);

// When the active pad or its buffer changes, push the pad's trim data into the knobs.
// knobSyncRef prevents the resulting k1/k2 change from immediately writing back to the store.
const knobSyncRef = useRef<"pad" | "user">("user");
useEffect(() => {
if (lastTriggeredPad === null || frameCount <= 0) return;
const pad = padMap[lastTriggeredPad];
const startFrac = (pad?.sampleStart ?? 0) / frameCount;
const endFrac = (pad?.sampleEnd ?? frameCount) / frameCount;
knobSyncRef.current = "pad";
setKnob("k1", Math.max(0, Math.min(1, startFrac)));
setKnob("k2", Math.max(0, Math.min(1, endFrac)));
}, [lastTriggeredPad, frameCount, setKnob, padMap]);

// When k1/k2 change, write the new trim to the store — unless we just synced from the pad.
useEffect(() => {
if (knobSyncRef.current === "pad") {
knobSyncRef.current = "user";
return;
}
if (lastTriggeredPad === null || frameCount <= 0) return;
const pad = padMap[lastTriggeredPad];
const startFrame = Math.round(k1 * frameCount);
const endFrame = Math.round(k2 * frameCount);
// Skip if values already match what's stored (avoids redundant engine calls)
if (startFrame === (pad?.sampleStart ?? 0) && endFrame === (pad?.sampleEnd ?? frameCount)) {
return;
}
if (startFrame < endFrame) {
setPadTrim(lastTriggeredPad, startFrame, endFrame);
}
}, [k1, k2, lastTriggeredPad, frameCount, padMap, setPadTrim]);

return (
<div className="screen">
<div className="screen-inner">
Expand Down Expand Up @@ -53,7 +105,12 @@ export function Screen({ engine }: ScreenProps) {
</div>

{/* Waveform */}
<Waveform engine={engine} />
<Waveform
engine={engine}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
/>

{/* Footer tabs */}
<div className="scr-foot">
Expand Down
21 changes: 21 additions & 0 deletions src/components/StartOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,31 @@ export function StartOverlay({ onStart }: StartOverlayProps) {
<b>Tweak</b> — click a pad's name or use the inspector panel to
adjust volume, pitch, and tune.
</li>
<li>
<b>Zoom</b> — use <b>+</b> / <b>−</b> keys or Ctrl/⌘+scroll to zoom;
press <b>0</b> to reset, or click <b>⊡ Fit</b> to fit the device to
the window. Drag the background to reposition.
</li>
<li>
<b>Sample Start/End</b> - use the start and end knob to set the
start and end of the sample.
</li>
<li>
<b>Export</b> — click <b>Export</b> to save a <code>.xpj</code>{" "}
project file ready for your MPC hardware.
</li>
<li>
<b>Desktop Version</b> - desktop version of the app has additional
features like auto-open export folder and SD card eject. Download{" "}
<a
href="https://github.com/WorldLinkStudio/mpcsample/releases"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500"
>
here
</a>
</li>
<li>
<b>Open Source</b> - Free and open source under the GPL-3.0 license
more info{" "}
Expand Down
172 changes: 162 additions & 10 deletions src/components/Waveform.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useWaveformDraw } from "../hooks/useWaveformDraw";
import { useMPCStore } from "../state/store";
import type { AudioEngineLike } from "../types/mpc.types";

type WaveformProps = {
engine: AudioEngineLike | null;
viewStart?: number;
viewEnd?: number;
onViewChange?: (start: number, end: number) => void;
};

export function Waveform({ engine }: WaveformProps) {
const MIN_SPAN = 0.0005;

export function Waveform({ engine, viewStart = 0, viewEnd = 1, onViewChange }: WaveformProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);

const visualizerMode = useMPCStore((s) => s.visualizerMode);
const lastTriggeredPad = useMPCStore((s) => s.lastTriggeredPad);
const padMap = useMPCStore((s) => s.padMap);
const _loadingPads = useMPCStore((s) => s.loadingPads);
const setPadTrim = useMPCStore((s) => s.setPadTrim);
const setKnob = useMPCStore((s) => s.setKnob);

// Keep a ref so getBuffer reads the latest pad without being recreated on
// every hit — avoids resetting lastWaveformBuf in useWaveformDraw each time
// a pad is triggered, which would cause a 1-frame flash to the flat line.
const lastTriggeredPadRef = useRef(lastTriggeredPad);
useEffect(() => {
lastTriggeredPadRef.current = lastTriggeredPad;
Expand All @@ -27,18 +35,162 @@ export function Waveform({ engine }: WaveformProps) {
const idx = lastTriggeredPadRef.current ?? 0;
const data = engine.getPadChannelData?.(idx) ?? null;
if (data !== null) return data;
// Null means either loading (keep previous) or truly empty (show blank).
// Return an empty array for empty pads so the draw loop resets to flat line.
return engine.isPadLoading(idx) ? null : new Float32Array(0);
}
return engine.getWaveform(); // oscilloscope
return engine.getWaveform();
}, [engine, visualizerMode]);

useWaveformDraw(canvasRef, engine ? getBuffer : null, visualizerMode);
useWaveformDraw(canvasRef, engine ? getBuffer : null, visualizerMode, viewStart, viewEnd);

// ── Trim state ────────────────────────────────────────────────────────────

const frameCount = useMemo(() => {
void _loadingPads;
if (!engine || lastTriggeredPad === null) return 0;
return engine.getPadChannelData?.(lastTriggeredPad)?.length ?? 0;
}, [engine, lastTriggeredPad, _loadingPads]);

const triggeredPadData = lastTriggeredPad !== null ? padMap[lastTriggeredPad] : null;
const trimStartFrames = triggeredPadData?.sampleStart ?? (frameCount > 0 ? 0 : null);
const trimEndFrames = triggeredPadData?.sampleEnd ?? (frameCount > 0 ? frameCount : null);

// Keep the current view in a ref so handlers always see fresh values
const viewRef = useRef({ start: viewStart, end: viewEnd });
viewRef.current = { start: viewStart, end: viewEnd };

// ── Zoom: non-passive wheel listener so preventDefault works ─────────────

useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const handleWheel = (e: WheelEvent) => {
// Let Ctrl/Meta+wheel bubble up to the viewport zoom handler
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
if (!onViewChange) return;
const rect = wrap.getBoundingClientRect();
const fx = (e.clientX - rect.left) / rect.width;
const { start, end } = viewRef.current;
const anchorFrac = start + fx * (end - start);
const span = end - start;
const factor = e.deltaY > 0 ? 1.25 : 1 / 1.25;
const newSpan = Math.min(1, Math.max(MIN_SPAN, span * factor));
let newStart = anchorFrac - fx * newSpan;
let newEnd = newStart + newSpan;
if (newStart < 0) {
newStart = 0;
newEnd = newSpan;
}
if (newEnd > 1) {
newEnd = 1;
newStart = 1 - newSpan;
}
onViewChange(newStart, newEnd);
};
wrap.addEventListener("wheel", handleWheel, { passive: false });
return () => wrap.removeEventListener("wheel", handleWheel);
}, [onViewChange]);

const handleDoubleClick = useCallback(() => {
onViewChange?.(0, 1);
}, [onViewChange]);

// ── Trim handle drag ──────────────────────────────────────────────────────

const trimDragRef = useRef<{
handle: "start" | "end";
otherFrames: number;
} | null>(null);

const handleTrimPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, which: "start" | "end") => {
e.stopPropagation();
if (lastTriggeredPad === null || frameCount <= 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
const currentStart = trimStartFrames ?? 0;
const currentEnd = trimEndFrames ?? frameCount;
trimDragRef.current = {
handle: which,
otherFrames: which === "start" ? currentEnd : currentStart,
};
},
[lastTriggeredPad, frameCount, trimStartFrames, trimEndFrames],
);

const handleTrimPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const drag = trimDragRef.current;
if (!drag || lastTriggeredPad === null || frameCount <= 0) return;
const wrap = wrapRef.current;
if (!wrap) return;
const rect = wrap.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const { start, end } = viewRef.current;
const bufFrac = start + frac * (end - start);
const newFrame = Math.round(bufFrac * frameCount);
if (drag.handle === "start") {
const clamped = Math.max(0, Math.min(drag.otherFrames - 1, newFrame));
setPadTrim(lastTriggeredPad, clamped, drag.otherFrames);
setKnob("k1", clamped / frameCount);
} else {
const clamped = Math.min(frameCount, Math.max(drag.otherFrames + 1, newFrame));
setPadTrim(lastTriggeredPad, drag.otherFrames, clamped);
setKnob("k2", clamped / frameCount);
}
},
[lastTriggeredPad, frameCount, setPadTrim, setKnob],
);

const handleTrimPointerUp = useCallback(() => {
trimDragRef.current = null;
}, []);

// ── Trim handle positioning ───────────────────────────────────────────────

const frameToPct = (frame: number) => {
if (frameCount <= 0) return 0;
const bufFrac = frame / frameCount;
const viewFrac = (bufFrac - viewStart) / (viewEnd - viewStart);
return Math.max(0, Math.min(100, viewFrac * 100));
};

const showTrimHandles =
visualizerMode === "waveform" &&
frameCount > 0 &&
trimStartFrames !== null &&
trimEndFrames !== null;

return (
<div className="waveform">
<div
ref={wrapRef}
role="application"
aria-label="Waveform view — scroll to zoom, double-click to reset"
className="waveform"
onDoubleClick={handleDoubleClick}
style={{ cursor: "default" }}
>
<canvas ref={canvasRef} />

{showTrimHandles && trimStartFrames !== null && trimEndFrames !== null && (
<>
<div
data-trim-handle="start"
className="trim-handle trim-handle-start"
style={{ left: `${frameToPct(trimStartFrames)}%` }}
onPointerDown={(e) => handleTrimPointerDown(e, "start")}
onPointerMove={handleTrimPointerMove}
onPointerUp={handleTrimPointerUp}
/>
<div
data-trim-handle="end"
className="trim-handle trim-handle-end"
style={{ left: `${frameToPct(trimEndFrames)}%` }}
onPointerDown={(e) => handleTrimPointerDown(e, "end")}
onPointerMove={handleTrimPointerMove}
onPointerUp={handleTrimPointerUp}
/>
</>
)}
</div>
);
}
Loading
Loading