Skip to content

Commit 52d9900

Browse files
authored
Merge pull request #432 from ford442/jules-tts-glide-curves-3925232671442961457
feat(audio): add exponential portamento glide and per-step breath intensity for TTS voices
2 parents 211bafc + a8cbe5e commit 52d9900

6 files changed

Lines changed: 63 additions & 17 deletions

File tree

agent_plan.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@
3737
- [x] **Refactor NoteSelector Focus Management:** Verified `NoteSelector` robustness with new test suite `src/components/__tests__/NoteSelector.test.tsx` mocking `requestAnimationFrame`.
3838
- [x] **Step-Sequenced Drive:** Allow individual sequencer steps to override the global Drive/Distortion amount for aggressive, rhythmic vocal accents.
3939

40+
- [x] **Glissando/Portamento Curves:** Allow users to draw custom pitch curves or select between Linear and Exponential glide types between steps. (Implemented Exponential Glide in `SingingVoice.ts`!)
41+
- [x] **Per-Step Breath Intensity:** Allow sequence steps to override global breathiness for rhythmic breathing and whisper effects. (Implemented in `useAudioEngine.ts`!)
42+
- [ ] **Custom Sample Slicing UI:** Add a waveform view to `SamplerPanel` that allows users to manually add, move, and remove transient markers for slicing a custom WAV file instead of just auto-slicing by phoneme.
43+
4044
### Domain C: Accessibility & Mobile
4145
- [x] **Touch Targets:** Audit `Sequencer.tsx` click listeners to ensure mobile drag-to-create works smoothly.
4246
- [x] **A11y Colors:** Verify high-contrast separation between `synth-1` (Chords) and `synth-2` (Lead) notes.
@@ -48,7 +52,6 @@
4852
* [x] **Idea:** "Spectral Granulator" - Add a granular synthesis mode to the sampler that uses FFT to freeze and smear TTS phonemes over time. (Implemented in Sampler and RubberBandProcessor via a 100ms looping Hann window!)
4953
* [x] **Idea:** "Chord Evolving" - Allow drawing automation curves for the chord inversions or voicings used by `VoiceManager` in Polyphonic Synth A. (Implemented via automation track logic in `useStepHandler.ts` and `App.tsx`!)
5054
* **Idea:** "Step-Sequenced Formant Shifts" - Allow users to pitch shift the formants of the TTS engine independently of the fundamental frequency per step.
51-
* **Idea:** "Custom Sample Slicing UI" - Add a waveform view to `SamplerPanel` that allows users to manually add, move, and remove transient markers for slicing a custom WAV file instead of just auto-slicing by phoneme.
5255
*These are concepts to be fleshed out by the agent during "Architect Mode".*
5356

5457
* [x] **Idea:** "Lyric Track" - A global text input that automatically distributes syllables across selected MIDI notes. (Implemented via global Lyric Track lane and `sliceIndex` auto-mapping!)
@@ -60,23 +63,23 @@
6063
* [x] **Idea:** "Vocal Envelope Shaper" - Add granular attack/decay ADSR shaping explicitly for TTS syllables to create sharp plucks or smooth pads from any word. (Implemented in ExpressiveVoiceProcessor and exposed to SamplerPanel!)
6164
* [x] **Idea:** "Dynamic Tremolo" - Expose Tremolo Rate and Depth to UI to pulse vocals. (Implemented!)
6265
* [x] **Idea:** "LFO to Freeze Amount" - Automate the Freeze parameter with an LFO to create rhythmic pulsing granular clouds. (Implemented in RubberBandProcessor and exposed to SamplerPanel!)
63-
* **Idea:** "LFO to Freeze Amount" - Automate the Freeze parameter with an LFO to create rhythmic pulsing granular clouds.
6466
* [x] **Idea:** "Per-Step Filter & Resonance" - Allow sequence steps to override cutoff and resonance for rhythmic acid-style filtering of TTS samples. (Implemented!)
6567
* [x] **Idea:** "Filter Envelope Mod" - Allow the sequence steps to have an envelope mod amount that specifically shapes the filter envelope per step. (Implemented!)
6668
* [x] **Idea:** "Vocal Formant LFO" - Introduce a Formant LFO with rate and depth controls for dynamic rhythmic Wah-Wah effects on TTS vowels. (Implemented in FormantShifter and SamplerPanel!)
6769
* [x] **Idea:** "Step-Sequenced Formant LFO" - Allow individual steps to override the global Formant LFO rate and depth for highly articulated rhythmic sequences.
6870
* **Idea:** "Custom Waveform LFO" - Allow users to draw custom LFO shapes for formant and freeze modulation.
69-
* **Idea:** "Glissando/Portamento Curve Drawing" - Allow users to draw custom pitch curves between steps, rather than just a linear glide.
71+
* [x] **Idea:** "Glissando/Portamento Curve Drawing" - Allow users to draw custom pitch curves between steps, rather than just a linear glide. (Implemented!)
7072
* [x] **Idea:** "Phoneme-Aware Velocity" - Automatically adjust the amplitude envelope attack/decay based on the phoneme type (e.g., plosives get faster attack, vowels get smoother attack). (Implemented via dynamic envelope overrides in `SingingVoice.ts`!)
7173
* [x] **Idea:** "Spectral Morphing" - Implement functionality to morph spectrally between two different TTS phonemes or samples over a sequence of steps. (Implemented via FormantShifter Voice Character Morphing and step-sequenced automation!)
72-
* [x] **Idea:** "Phoneme-Aware Velocity" - Automatically adjust the amplitude envelope attack/decay based on the phoneme type (e.g., plosives get faster attack, vowels get smoother attack). (Implemented via dynamic envelope scaling in `triggerSlice`!)
7374
* [x] **Idea:** "Dynamic Reverb" - Allow users to draw automation curves for reverb send per step. (Implemented!)
7475
* [x] **Idea:** "Global Saturation / Tape Warmth" - Add a master channel saturation unit to glue the mix together. (Implemented via WaveShaperNode!)
7576
* **Idea:** "AI Auto-Mix Assistant" - Automatically adjusts levels, panning, and EQ based on track content to maintain a balanced mix.
77+
* **Idea:** "Per-Step Breath Intensity" - Allow sequence steps to override global breathiness for rhythmic breathing and whisper effects. (Implemented!)
7678

7779
---
7880

7981
## 📜 Changelog
82+
* [2026-06-20] - Implemented Glissando/Portamento Curves & Per-Step Breath Intensity: Added `slideType` parameter (Linear/Exponential) to allow musical variations of pitch glides in TTS, and allowed individual steps to override global breath noise via `breathIntensity`. Added Custom Sample Slicing UI to Active Backlog.
8083
* [2026-06-19] - Implemented Global Saturation: Added a master channel `WaveShaperNode` with a variable distortion curve mapped to a "Warmth" (Saturation) slider in the top utility UI. Routed the entire master mix through it to add glue and presence. Added new idea: "AI Auto-Mix Assistant".
8184
* [2026-06-18] - Implemented Dynamic Reverb: Added a `ConvolverNode` hooked up to the master output with a generated exponential decay noise impulse response. Mapped `reverbSend` from individual sequence steps in `NoteSelector` to send audio from the TTS `SingingVoice` into the new global reverb bus. Added new idea: "Global Saturation / Tape Warmth".
8285
* [2026-06-17] - Implemented Spectral Morphing: Added `characterMorph` and `morphTarget` to allow smooth interpolation between voice characters (e.g., male to female) per sequence step using `FormantShifter.ts`. Added Morph knob and target selector to `SamplerPanel.tsx` and override controls to `NoteSelector.tsx`.

src/components/AISongModal.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1153,11 +1153,10 @@ export function AISongModal({ isOpen, onClose, onImport, onShowToast, audioEngin
11531153
})
11541154
) : null}
11551155
</div>
1156+
<div className="space-y-1.5">
1157+
{trackStatisticsRows.length > 0 && (trackStatisticsRows as React.ReactNode)}
1158+
</div>
11561159
</div>
1157-
<div className="space-y-1.5">
1158-
{trackStatisticsRows.length > 0 && (trackStatisticsRows as React.ReactNode)}
1159-
</div>
1160-
</div>
11611160
) : null}
11621161

11631162
{/* Automation Visualization */}

src/components/SamplerVoicePanel.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import { type HarmonizerConfig } from '../engines/Harmonizer';
2-
import React, { useState, useRef } from 'react';
1+
import { type HarmonizerConfig, type HarmonyType, HARMONIZE_PRESETS } from '../engines/Harmonizer';
2+
import React, { useState, useRef, useCallback } from 'react';
33
import { HardwareModule, type KnobConfig } from './HardwareModule';
44
import { LadderButton } from './sampler/LadderButton';
5-
import { VerticalKnob } from './sampler/VerticalKnob';
6-
import { HSlider } from './sampler/HSlider';
7-
import { HarmonizerPopover } from './sampler/HarmonizerPopover';
85

96
interface SamplerVoicePanelProps {
107
title: string;

src/engines/SingingVoice.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,17 @@ export class SingingVoice {
255255
}
256256
}
257257

258+
/**
259+
* Exponentially ramp the pitch scale ratio to the given target.
260+
* @param ratio Target pitch multiplier
261+
* @param time Time to reach the target ratio
262+
*/
263+
exponentialRampToPitch(ratio: number, time: number): void {
264+
if (this.workletNode) {
265+
this.workletNode.parameters.get('pitchScale')!.exponentialRampToValueAtTime(ratio, time);
266+
}
267+
}
268+
258269
/**
259270
* Linearly ramp the pitch from current value to the target MIDI note.
260271
*/
@@ -275,6 +286,26 @@ export class SingingVoice {
275286
this.linearRampToPitch(pitchRatio, time || this.audioContext.currentTime);
276287
}
277288

289+
/**
290+
* Exponentially ramp the pitch from current value to the target MIDI note.
291+
*/
292+
exponentialRampPitchFromMidi(targetMidiNote: number, baseMidiNote?: number, time?: number, coarseTune?: number, fineTune?: number): void {
293+
const effectiveBaseNote = baseMidiNote ?? this.rootNote;
294+
const effectiveCoarse = coarseTune ?? this.coarseTune;
295+
const effectiveFine = fineTune ?? this.fineTune;
296+
297+
const totalSemitoneOffset = effectiveCoarse + (effectiveFine / 100);
298+
const adjustedTargetMidi = targetMidiNote + totalSemitoneOffset;
299+
300+
const targetFreq = midiToFreq(adjustedTargetMidi);
301+
const baseFreq = midiToFreq(effectiveBaseNote);
302+
303+
let pitchRatio = targetFreq / baseFreq;
304+
pitchRatio = Math.max(PITCH_RATIO_LIMITS.MIN, Math.min(PITCH_RATIO_LIMITS.MAX, pitchRatio));
305+
306+
this.exponentialRampToPitch(pitchRatio, time || this.audioContext.currentTime);
307+
}
308+
278309
/**
279310
* Set pitch from MIDI note number relative to base note.
280311
* Uses stored rootNote, coarseTune, and fineTune values.

src/hooks/useAudioEngine.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export const useAudioEngine = (pyodide: unknown) => {
272272
sliceIndex?: number,
273273
retrigger?: number,
274274
slideFromMidi?: number,
275+
slideType?: 'linear' | 'exponential',
275276
phonemes?: PhonemeData[],
276277
freeze?: number,
277278
filterCutoff?: number,
@@ -281,7 +282,8 @@ export const useAudioEngine = (pyodide: unknown) => {
281282
vibratoDepth?: number,
282283
reverbSend?: number,
283284
drive?: number,
284-
characterMorph?: number
285+
characterMorph?: number,
286+
breathIntensity?: number
285287
},
286288
pitchOffsetSemitones: number = 0
287289
) => {
@@ -383,7 +385,11 @@ export const useAudioEngine = (pyodide: unknown) => {
383385
}
384386
if (params.tremoloDepth !== undefined) voice.setTremoloDepth(params.tremoloDepth, triggerTime);
385387
if (params.tremoloRate !== undefined) voice.setTremoloRate(params.tremoloRate, triggerTime);
386-
if (params.breathIntensity !== undefined) voice.setBreathIntensity(params.breathIntensity, triggerTime);
388+
if (noteParams?.breathIntensity !== undefined) {
389+
voice.setBreathIntensity(noteParams.breathIntensity, triggerTime);
390+
} else if (params.breathIntensity !== undefined) {
391+
voice.setBreathIntensity(params.breathIntensity, triggerTime);
392+
}
387393
if (params.attack !== undefined) voice.setAttack(params.attack, triggerTime);
388394
if (params.decay !== undefined) voice.setDecay(params.decay, triggerTime);
389395
if (params.sustain !== undefined) voice.setSustain(params.sustain, triggerTime);
@@ -444,7 +450,12 @@ export const useAudioEngine = (pyodide: unknown) => {
444450
voice.setPitchFromMidi(startMidi + pitchOffset, 60, triggerTime);
445451
// Glide over half the target duration or a minimum of 0.15s, bounded by actual duration
446452
const glideDuration = Math.min(Math.max(targetDuration * 0.5, 0.15), targetDuration);
447-
voice.linearRampPitchFromMidi(targetMidi + pitchOffset, 60, triggerTime + glideDuration);
453+
454+
if (noteParams?.slideType === 'exponential' || params.portamentoType === 'exponential') {
455+
voice.exponentialRampPitchFromMidi(targetMidi + pitchOffset, 60, triggerTime + glideDuration);
456+
} else {
457+
voice.linearRampPitchFromMidi(targetMidi + pitchOffset, 60, triggerTime + glideDuration);
458+
}
448459
} else {
449460
voice.setPitchFromMidi(targetMidi + pitchOffset, 60, triggerTime);
450461
}
@@ -619,14 +630,16 @@ export const useAudioEngine = (pyodide: unknown) => {
619630
sliceIndex?: number,
620631
retrigger?: number,
621632
slideFromMidi?: number,
633+
slideType?: 'linear' | 'exponential',
622634
phonemes?: PhonemeData[],
623635
freeze?: number,
624636
filterCutoff?: number,
625637
filterResonance?: number,
626638
vibratoDepth?: number,
627639
reverbSend?: number,
628640
drive?: number,
629-
characterMorph?: number
641+
characterMorph?: number,
642+
breathIntensity?: number
630643
}
631644
) => {
632645
// Harmonize support - if harmonizer is active, generate multiple harmony voices

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export interface SamplerBankParams {
7575
choir?: number; // Choir effect amount (0-1) - Detuned side voices
7676
glitchChance?: number; // Probability of glitch/stutter effect (0-1)
7777
freeze?: number; // Freeze/smear amount (0-1)
78+
portamentoType?: 'linear' | 'exponential'; // Glide curve shape
7879
freezeLfoRate?: number; // Freeze LFO rate (Hz)
7980
freezeLfoDepth?: number; // Freeze LFO depth (0-1)
8081
formantLfoRate?: number; // Formant LFO rate (Hz)
@@ -166,6 +167,8 @@ export interface Note {
166167
vibratoDepth?: number; // 0-100%, vibrato depth override (Sampler only)
167168
reverbSend?: number; // 0-1, amount sent to reverb bus
168169
drive?: number; // 0-1, distortion/drive amount override (Sampler only)
170+
slideType?: 'linear' | 'exponential'; // Shape of portamento curve
171+
breathIntensity?: number; // 0-1, override breath noise amount (Sampler only)
169172

170173
// Phase 2: Melodic Lyric Mode - Per-step pitch control
171174
pitch?: number; // MIDI note number for sampler melodic mode (default: 60 = C4)

0 commit comments

Comments
 (0)