From 82b4f4b85542b4015dadecb2d41105e6b13b8658 Mon Sep 17 00:00:00 2001 From: David Menting Date: Tue, 7 Jul 2026 15:07:12 +0200 Subject: [PATCH 01/24] Rework sample recorder trim and controls - Make the yellow trim bars in the waveform draggable (with center handle circles), replacing the horizontal start/end sliders - Merge record and record-again into one round button left of the waveform; first press opens the microphone and records immediately, removing the separate turn-on-microphone button - Keep play and save buttons always visible (disabled when unusable), play/save now sit right of the gain slider - Add browse and factory-reset buttons next to the record button; both load audio into the capture buffer for trim/gain adjustment before saving to the device - Extract FACTORY_SAMPLES map to shared config - SampleCapture: add load() for external buffers, playback without an open mic stream, reset() falls back to idle - Move mic selector below the buttons; drop unused locale keys --- src/lib/components/SampleRecorder.svelte | 208 +++++++++++++++------- src/lib/components/Voice.svelte | 37 +--- src/lib/components/WaveformDisplay.svelte | 69 ++++++- src/lib/config/factorySamples.ts | 37 ++++ src/lib/services/sampleCapture.svelte.ts | 39 +++- src/locales/de.json | 7 +- src/locales/en.json | 7 +- src/locales/nl.json | 7 +- 8 files changed, 282 insertions(+), 129 deletions(-) create mode 100644 src/lib/config/factorySamples.ts diff --git a/src/lib/components/SampleRecorder.svelte b/src/lib/components/SampleRecorder.svelte index 1412cd8..0941ba6 100644 --- a/src/lib/components/SampleRecorder.svelte +++ b/src/lib/components/SampleRecorder.svelte @@ -18,6 +18,7 @@ floatTo16BitPcm } from '$lib/services/audioAnalysis'; import WaveformDisplay from './WaveformDisplay.svelte'; + import { FACTORY_SAMPLES } from '$lib/config/factorySamples'; import { createLogger } from '$lib/utils/logger'; const logger = createLogger('SampleRecorder'); @@ -37,22 +38,35 @@ ); let gainFactor = $derived(Math.pow(10, gainDb / 20)); - async function startMonitoring() { + async function startMonitoring(): Promise { errorMessage = null; const granted = await requestPermission(); if (!granted) { errorMessage = audioInputState.error; - return; + return false; } await initializeAudioInput(); try { await capture.open(audioInputState.selectedDeviceId ?? undefined); + return true; } catch (error) { errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to start monitoring: ${errorMessage}`); + return false; + } + } + + async function onRecordClick() { + if (capture.status === 'recorded') { + capture.reset(); // falls back to idle when the mic was never opened + } + if (capture.status === 'idle') { + const opened = await startMonitoring(); + if (!opened) return; // permission denied or open failed } + await startRecording(); } async function startRecording() { @@ -141,6 +155,71 @@ } } + let fileInput: HTMLInputElement; + + const MAX_LOAD_S = 5; // cap loaded files so the trim view stays usable + + /** + * Decode an audio file into the capture buffer (mono, capture sample rate) + * so it can be trimmed and gain-adjusted like a recording. + */ + async function loadIntoBuffer(arrayBuffer: ArrayBuffer, name: string) { + errorMessage = null; + const ctx = new AudioContext(); + try { + const audioBuffer = await ctx.decodeAudioData(arrayBuffer); + let samples = audioBuffer.getChannelData(0); + if (audioBuffer.numberOfChannels > 1) { + const right = audioBuffer.getChannelData(1); + samples = samples.map((v, i) => (v + right[i]) * 0.5); + } + if (audioBuffer.sampleRate !== capture.sampleRate) { + samples = resampleLinear(samples, audioBuffer.sampleRate, capture.sampleRate); + } + if (samples.length > MAX_LOAD_S * capture.sampleRate) { + samples = samples.slice(0, MAX_LOAD_S * capture.sampleRate); + } + capture.load(samples, capture.sampleRate); + gainDb = 0; + autoTrim(samples); + logger.info(`Loaded ${name} into buffer (${samples.length} samples)`); + } catch (error) { + errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Failed to load ${name}: ${errorMessage}`); + } finally { + ctx.close(); + } + } + + async function handleFileSelect(event: Event) { + const input = event.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + if (file) await loadIntoBuffer(await file.arrayBuffer(), file.name); + input.value = ''; + } + + async function resetToFactory() { + const slot = midiNoteState.selectedSample; + if (slot === null) return; + + const filename = FACTORY_SAMPLES[slot]; + if (!filename) { + errorMessage = $_('recorder_no_factory_sample'); + return; + } + + try { + const response = await fetch(`/factory_kit/${filename}`); + if (!response.ok) { + throw new Error(`Failed to load factory sample: ${response.statusText}`); + } + await loadIntoBuffer(await response.arrayBuffer(), filename); + } catch (error) { + errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Factory sample load failed: ${errorMessage}`); + } + } + function playSelection() { capture.play(getSelection()); } @@ -163,13 +242,41 @@
-

{$_('recorder_title')}

{#if errorMessage}

{errorMessage}

{/if}
+
+ + +
+
(gainDb = Number(e.currentTarget.value))} aria-label="{$_('recorder_quieter')} / {$_('recorder_louder')}" /> - {gainDb > 0 ? '+' : ''}{gainDb} dB +
+
+ +
- {#if capture.status === 'idle'} - - {:else} + {#if capture.status !== 'idle'} {#if audioInputState.devices.length > 1} {/if} - - {#if capture.status === 'monitoring' || capture.status === 'recording'} - - {/if} - - {#if capture.status === 'recorded' && capture.recorded} -
- - -
- -
- - - -
- {/if} {/if} + +