diff --git a/README.md b/README.md index e51d75b..af57c30 100644 --- a/README.md +++ b/README.md @@ -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._ diff --git a/src/audio/SampleEngine.ts b/src/audio/SampleEngine.ts index 522cd74..3a0722b 100644 --- a/src/audio/SampleEngine.ts +++ b/src/audio/SampleEngine.ts @@ -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); } @@ -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); } } diff --git a/src/components/Knob.tsx b/src/components/Knob.tsx index 1dcc847..0565432 100644 --- a/src/components/Knob.tsx +++ b/src/components/Knob.tsx @@ -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"; @@ -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) => { diff --git a/src/components/MPCDevice.tsx b/src/components/MPCDevice.tsx index e323691..8f1c487 100644 --- a/src/components/MPCDevice.tsx +++ b/src/components/MPCDevice.tsx @@ -137,8 +137,8 @@ export function MPCDevice({ engine = null }: MPCDeviceProps) { {/* CENTER COLUMN: knobs + bank selector + pad grid */}
- - + +
diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx index 6f95e77..98f5fa2 100644 --- a/src/components/Screen.tsx +++ b/src/components/Screen.tsx @@ -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"; @@ -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; @@ -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 (
@@ -53,7 +105,12 @@ export function Screen({ engine }: ScreenProps) {
{/* Waveform */} - + {/* Footer tabs */}
diff --git a/src/components/StartOverlay.tsx b/src/components/StartOverlay.tsx index 68ed44f..3ccaedc 100644 --- a/src/components/StartOverlay.tsx +++ b/src/components/StartOverlay.tsx @@ -108,10 +108,31 @@ export function StartOverlay({ onStart }: StartOverlayProps) { Tweak — click a pad's name or use the inspector panel to adjust volume, pitch, and tune. +
  • + Zoom — use + / keys or Ctrl/⌘+scroll to zoom; + press 0 to reset, or click ⊡ Fit to fit the device to + the window. Drag the background to reposition. +
  • +
  • + Sample Start/End - use the start and end knob to set the + start and end of the sample. +
  • Export — click Export to save a .xpj{" "} project file ready for your MPC hardware.
  • +
  • + Desktop Version - desktop version of the app has additional + features like auto-open export folder and SD card eject. Download{" "} + + here + +
  • Open Source - Free and open source under the GPL-3.0 license more info{" "} diff --git a/src/components/Waveform.tsx b/src/components/Waveform.tsx index 1b51e41..0e93e61 100644 --- a/src/components/Waveform.tsx +++ b/src/components/Waveform.tsx @@ -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(null); + const wrapRef = useRef(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; @@ -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, 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) => { + 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 ( -
    +
    + + {showTrimHandles && trimStartFrames !== null && trimEndFrames !== null && ( + <> +
    handleTrimPointerDown(e, "start")} + onPointerMove={handleTrimPointerMove} + onPointerUp={handleTrimPointerUp} + /> +
    handleTrimPointerDown(e, "end")} + onPointerMove={handleTrimPointerMove} + onPointerUp={handleTrimPointerUp} + /> + + )}
    ); } diff --git a/src/components/__tests__/Waveform.test.tsx b/src/components/__tests__/Waveform.test.tsx index 7a6157e..defa36c 100644 --- a/src/components/__tests__/Waveform.test.tsx +++ b/src/components/__tests__/Waveform.test.tsx @@ -6,8 +6,7 @@ import { Waveform } from "../Waveform"; // Mock useWaveformDraw to capture calls const mockUseWaveformDraw = vi.fn(); vi.mock("../../hooks/useWaveformDraw", () => ({ - useWaveformDraw: (ref: unknown, getBuffer: unknown, mode: unknown) => - mockUseWaveformDraw(ref, getBuffer, mode), + useWaveformDraw: (...args: unknown[]) => mockUseWaveformDraw(...args), })); beforeEach(() => { @@ -37,6 +36,8 @@ describe("Waveform", () => { expect.objectContaining({ current: expect.anything() }), null, expect.any(String), + expect.any(Number), + expect.any(Number), ); }); diff --git a/src/hooks/useWaveformDraw.ts b/src/hooks/useWaveformDraw.ts index 910981d..0c3f522 100644 --- a/src/hooks/useWaveformDraw.ts +++ b/src/hooks/useWaveformDraw.ts @@ -19,6 +19,8 @@ export function useWaveformDraw( canvasRef: React.RefObject, getBuffer: (() => Float32Array | null) | null, mode: "waveform" | "fft" | "oscilloscope" = "waveform", + viewStart = 0, + viewEnd = 1, ): void { useEffect(() => { const canvas = canvasRef.current; @@ -30,8 +32,10 @@ export function useWaveformDraw( let stopped = false; // Peak hold state for FFT mode let peaks: Float32Array | null = null; - // Cache for waveform mode — skip redraw when reference unchanged + // Cache for waveform mode — skip redraw when buffer reference AND view window are unchanged let lastWaveformBuf: Float32Array | null = null; + let lastViewStart = -1; + let lastViewEnd = -1; function resizeCanvas(): void { if (!canvas) return; @@ -56,14 +60,16 @@ export function useWaveformDraw( if (mode === "waveform") { const buf = getBuffer ? getBuffer() : null; - // Skip redraw — and crucially skip clearRect — when the buffer is - // unchanged or when the new pad's buffer is still loading (null). - // This keeps the previous waveform visible instead of flashing to blank. - if (lastWaveformBuf !== null && (buf === lastWaveformBuf || buf === null)) { + // Skip redraw when both the buffer reference AND the view window are unchanged. + // A null buf while loading keeps the previous waveform visible (no flash to blank). + const viewUnchanged = lastViewStart === viewStart && lastViewEnd === viewEnd; + if (lastWaveformBuf !== null && viewUnchanged && (buf === lastWaveformBuf || buf === null)) { rafId = requestAnimationFrame(drawFrame); return; } lastWaveformBuf = buf; + lastViewStart = viewStart; + lastViewEnd = viewEnd; ctx.clearRect(0, 0, w, h); @@ -104,10 +110,12 @@ export function useWaveformDraw( ctx.fillStyle = "#ffd200"; + const winStart = viewStart * N; + const winSize = (viewEnd - viewStart) * N; for (let x = 0; x < w; x++) { - // Map column → sample range (even a 1-sample slice still paints) - const sStart = Math.floor((x / w) * N); - const sEnd = Math.max(sStart + 1, Math.floor(((x + 1) / w) * N)); + // Map column → sample range within the zoom window + const sStart = Math.floor(winStart + (x / w) * winSize); + const sEnd = Math.max(sStart + 1, Math.floor(winStart + ((x + 1) / w) * winSize)); let minS = 0; let maxS = 0; @@ -271,5 +279,5 @@ export function useWaveformDraw( window.removeEventListener("resize", resizeCanvas); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [canvasRef, getBuffer, mode]); + }, [canvasRef, getBuffer, mode, viewStart, viewEnd]); } diff --git a/src/kits/kit.types.ts b/src/kits/kit.types.ts index 5361a1c..0cfc87a 100644 --- a/src/kits/kit.types.ts +++ b/src/kits/kit.types.ts @@ -96,6 +96,20 @@ export type SamplePad = { * Default: 0.5. Serialised with a decimal point. */ pan: number; + + /** + * Trim start point in sample frames (inclusive). + * Written to `instruments[idx].layersv[0].sampleStart` and + * `sliceInfo.Start` in the `.xpj`. Absent = start from frame 0. + */ + sampleStart?: number; + + /** + * Trim end point in sample frames (exclusive). + * Written to `instruments[idx].layersv[0].sampleEnd` and + * `sliceInfo.End` in the `.xpj`. Absent = play to the last frame. + */ + sampleEnd?: number; }; /** diff --git a/src/state/store.ts b/src/state/store.ts index e43a786..46901d9 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -268,6 +268,9 @@ type Actions = { /** Update linear gain for a pad and forward to the engine. */ setPadGain: (idx: GlobalPadIdx, gainCoefficient: number) => void; + /** Update trim start/end (in sample frames) for a pad and forward to the engine. */ + setPadTrim: (idx: GlobalPadIdx, startFrames: number, endFrames: number) => void; + /** * Rename the exported kit name (writes to `activeKit.exportName`). * Creates an immutable clone of `activeKit` with the new name. @@ -570,6 +573,21 @@ export const useMPCStore = create((set, get) => ({ engineRef?.setPadGain(idx, clampedGain); }, + setPadTrim: (idx, startFrames, endFrames) => { + const { padMap, engineRef } = get(); + const existing = padMap[idx]; + if (!existing) return; + const clampedStart = Math.max(0, Math.round(startFrames)); + const clampedEnd = Math.max(clampedStart + 1, Math.round(endFrames)); + set({ + padMap: { + ...padMap, + [idx]: { ...existing, sampleStart: clampedStart, sampleEnd: clampedEnd }, + }, + }); + engineRef?.setPadTrim(idx, clampedStart, clampedEnd); + }, + setKitName: (name) => { const { activeKit } = get(); if (!activeKit) return; diff --git a/src/styles/global.css b/src/styles/global.css index 8c9145d..bd2e252 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -20,10 +20,10 @@ body { } body { - min-height: 100vh; + height: 100vh; + overflow: hidden; display: grid; place-items: center; - padding: 32px 16px 80px; background: radial-gradient(1200px 700px at 50% 30%, #1f3a5e 0%, #15233b 45%, #07101e 100%); } diff --git a/src/styles/layout.css b/src/styles/layout.css index 99ab779..f6cb211 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -309,6 +309,33 @@ display: block; } +.trim-handle { + position: absolute; + top: 0; + bottom: 0; + width: 3px; + transform: translateX(-50%); + z-index: 10; + cursor: ew-resize; + touch-action: none; +} + +.trim-handle::after { + content: ""; + position: absolute; + inset: 0 -4px; +} + +.trim-handle-start { + background: rgba(60, 220, 130, 0.85); + box-shadow: 0 0 6px rgba(60, 220, 130, 0.5); +} + +.trim-handle-end { + background: rgba(255, 90, 90, 0.85); + box-shadow: 0 0 6px rgba(255, 90, 90, 0.5); +} + /* Speaker grille — col 4. Dot-pattern grille per prototype. */ .speaker { height: 100px; diff --git a/src/types/mpc.types.ts b/src/types/mpc.types.ts index 3ebbf94..b4d5754 100644 --- a/src/types/mpc.types.ts +++ b/src/types/mpc.types.ts @@ -122,6 +122,13 @@ export interface AudioEngineLike { */ setPadGain(idx: PadIndex, gainCoefficient: number): void; + /** + * Set the trim region for a pad (sample frames, matching the `.xpj` unit). + * Applied as `player.start(time, startSec, durationSec)` during playback. + * Pass startFrames=0 and endFrames=totalFrames to restore full-length playback. + */ + setPadTrim(idx: PadIndex, startFrames: number, endFrames: number): void; + /** * Returns `true` while the sample buffer for `idx` is being fetched / * decoded. Drives the per-pad loading indicator in the UI. diff --git a/src/xpj/buildXpj.ts b/src/xpj/buildXpj.ts index 05a1952..41be63e 100644 --- a/src/xpj/buildXpj.ts +++ b/src/xpj/buildXpj.ts @@ -232,11 +232,13 @@ export function assignPad(instruments: Instrument[], pad: SamplePad, sampleEndFr // Playback region: start at 0, end at the sample's frame count. Without a // non-zero end the MPC loads the project but never sounds the pad. if (sampleEndFrames > 0) { - layer.sampleStart = 0; - layer.sampleEnd = sampleEndFrames; + const start = pad.sampleStart ?? 0; + const end = pad.sampleEnd ?? sampleEndFrames; + layer.sampleStart = start; + layer.sampleEnd = end; if (layer.sliceInfo) { - layer.sliceInfo.Start = 0; - layer.sliceInfo.End = sampleEndFrames; + layer.sliceInfo.Start = start; + layer.sliceInfo.End = end; } } }