Sound FX: Pitch in Echtzeit, ohne die Länge zu ändern #317

Closed
opened 2026-10-05 22:24:36 +02:00 by meleki · 1 comment
Owner

Abgezweigt aus #315 und dem length-Ausgang an Play sound.

Pitch hat heute zwei Wege, und nur einer hält die Länge:

  • Pitch beim Start (#312): Play sound rechnet vorab eine verschobene Kopie des ganzen Sounds mit WSOLA (src/core/soundfx/pitchShift.ts). Die Kopie hat die neue Tonhöhe und dieselbe Länge.
  • Pitch an einer laufenden Stimme (#315, Set sound FX parameters): Die Kopie spielt schon und kann nicht neu gerechnet werden. Der Player dreht deshalb an playbackRate der Quelle (src/viewport/SoundLibrary.ts, move(knob.source.playbackRate, …)). 12 Halbtöne tiefer heißt halbe Geschwindigkeit, der Rest des Sounds dauert doppelt so lang, wie bei einer Schallplatte.

Folgen:

  • Ein Pitch-Sweep über time = length deckt nicht den ganzen Clip ab. Nach unten läuft der Sound über das Ende der Rampe hinaus, der Rest kommt auf der Endtonhöhe. Nach oben ist der Sound vorbei, bevor die Rampe ihr Ziel erreicht.
  • Beispiel: ein 0,25-s-Blip mit −50 Halbtönen/s endet rechnerisch erst nach etwa 0,44 s, bei rund −22 Halbtönen (nachgerechnet, nicht gemessen).

Gewünscht: Pitch ändert nie die Länge, weder beim Start noch mittendrin.

Vorschlag:

  • Pitch-Shifter in Echtzeit als AudioWorklet im Audio-Thread (WSOLA oder Ähnliches, mit dem bestehenden pitchShift als Vorbild/Referenz für die Tests).
  • Der Pitch wird dort ein AudioParam des Worklets, damit setTargetAtTime, lineare/exponentielle Rampen und setValueCurveAtTime (Easings aus e7a360d) wie bei Gain und EQ funktionieren.
  • Das Vorab-Rechnen der Kopie samt Cache (letzte 32) fiele weg.
  • Ein Mix mit Pitch auf nur einem Weg (#316) braucht dann keine zweite Quelle mehr, sondern ein Worklet je Weg.

Offene Fragen:

  • Rechenzeit: Das Worklet rechnet, solange die Stimme läuft, nicht einmal beim Start. Wie viele gleichzeitige Stimmen mit Pitch verträgt ein schwacher Rechner?
  • Latenz: WSOLA braucht ein Fenster im Voraus. Wie weit verzögert das den Start, und darf der Ton dafür später einsetzen oder muss das Fenster vorgefüllt werden?
  • Klang: Bei kurzen, harten Sounds verschmiert WSOLA den Anschlag, in Echtzeit vermutlich ähnlich wie jetzt die Kopie. Ein Pitch von 0 sollte das Worklet ganz umgehen.
  • Loop: Die Kopie wird heute über die Nahtstelle hinweg gestreckt. Das Worklet muss die Nahtstelle ebenso sauber überbrücken.
  • Tests: Im e2e-Browser ist der Ton stumm. Messen ließe sich die Länge der Stimme und die Frequenz über einen AnalyserNode oder einen OfflineAudioContext.
Abgezweigt aus #315 und dem `length`-Ausgang an `Play sound`. Pitch hat heute zwei Wege, und nur einer hält die Länge: - **Pitch beim Start (#312):** `Play sound` rechnet vorab eine verschobene Kopie des ganzen Sounds mit WSOLA (`src/core/soundfx/pitchShift.ts`). Die Kopie hat die neue Tonhöhe und **dieselbe Länge**. - **Pitch an einer laufenden Stimme (#315, `Set sound FX parameters`):** Die Kopie spielt schon und kann nicht neu gerechnet werden. Der Player dreht deshalb an `playbackRate` der Quelle (`src/viewport/SoundLibrary.ts`, `move(knob.source.playbackRate, …)`). 12 Halbtöne tiefer heißt halbe Geschwindigkeit, der Rest des Sounds dauert doppelt so lang, wie bei einer Schallplatte. Folgen: - Ein Pitch-Sweep über `time` = `length` deckt nicht den ganzen Clip ab. Nach unten läuft der Sound über das Ende der Rampe hinaus, der Rest kommt auf der Endtonhöhe. Nach oben ist der Sound vorbei, bevor die Rampe ihr Ziel erreicht. - Beispiel: ein 0,25-s-Blip mit −50 Halbtönen/s endet rechnerisch erst nach etwa 0,44 s, bei rund −22 Halbtönen (nachgerechnet, nicht gemessen). Gewünscht: **Pitch ändert nie die Länge**, weder beim Start noch mittendrin. Vorschlag: - Pitch-Shifter in Echtzeit als `AudioWorklet` im Audio-Thread (WSOLA oder Ähnliches, mit dem bestehenden `pitchShift` als Vorbild/Referenz für die Tests). - Der Pitch wird dort ein `AudioParam` des Worklets, damit `setTargetAtTime`, lineare/exponentielle Rampen und `setValueCurveAtTime` (Easings aus `e7a360d`) wie bei Gain und EQ funktionieren. - Das Vorab-Rechnen der Kopie samt Cache (letzte 32) fiele weg. - Ein Mix mit Pitch auf nur einem Weg (#316) braucht dann keine zweite Quelle mehr, sondern ein Worklet je Weg. Offene Fragen: - **Rechenzeit:** Das Worklet rechnet, solange die Stimme läuft, nicht einmal beim Start. Wie viele gleichzeitige Stimmen mit Pitch verträgt ein schwacher Rechner? - **Latenz:** WSOLA braucht ein Fenster im Voraus. Wie weit verzögert das den Start, und darf der Ton dafür später einsetzen oder muss das Fenster vorgefüllt werden? - **Klang:** Bei kurzen, harten Sounds verschmiert WSOLA den Anschlag, in Echtzeit vermutlich ähnlich wie jetzt die Kopie. Ein Pitch von 0 sollte das Worklet ganz umgehen. - **Loop:** Die Kopie wird heute über die Nahtstelle hinweg gestreckt. Das Worklet muss die Nahtstelle ebenso sauber überbrücken. - **Tests:** Im e2e-Browser ist der Ton stumm. Messen ließe sich die Länge der Stimme und die Frequenz über einen `AnalyserNode` oder einen `OfflineAudioContext`.
admin added this to the Development project 2026-10-05 22:29:28 +02:00
admin moved this to Next in Development on 2026-10-05 22:30:33 +02:00
admin moved this to Held back in Development on 2026-10-05 22:31:01 +02:00
admin added the held-back label 2026-10-05 22:31:03 +02:00
admin added high-priority and removed held-back labels 2026-10-06 07:15:41 +02:00
Author
Owner

Umgesetzt in 6e70421 („Keep the length of a sound whatever its pitch does #317“), Wiki in 6591188 (Sounds, Architecture).

Entscheidungen

  1. Worklet als Abspieler an der Quelle, nicht als Effekt in der Kette. Es liest den Sound selbst und schaut voraus: Der Ton setzt ohne Latenz mit dem ersten Sample ein, die Wege eines Mix bleiben deckungsgleich. Ein EQ vor dem Pitch wirkt deshalb weiter wie danach.
  2. Phase-Vocoder statt WSOLA, selbst geschrieben (src/core/soundfx/pitchPlayer.ts): Frames von etwa 43 ms, vierfach überlappt, reelle FFT über eine halb so große komplexe. Dazu Phase Locking (Laroche/Dolson) und Zero-phase-Fenster – ohne beides hat ein Ton gegen sich selbst geschwebt und die Lautstärke fiel bis 0,54.
  3. Kein Limit an Stimmen, gemessen und im Wiki dokumentiert.
  4. Kein engerer Bereich als die −64 bis +64 des Nodes.

Was jetzt gilt

  • Pitch ist ein AudioParam in Halbtönen. Er gleitet, rampt und easet wie Gain, und die Länge ändert sich nie mehr – weder beim Start noch mitten im Sound. length → time reicht damit auch für einen Pitch-Sweep über den ganzen Sound.
  • Ein Weg ohne Pitch bleibt eine normale AudioBufferSourceNode. Die vorab gerechneten WSOLA-Kopien samt Cache (pitchShift.ts) sind weg.
  • Das Worklet (src/viewport/pitchWorklet.ts, von Vite über ?worker&url als eigenes Bundle, 5,8 kB) lädt mit dem ersten Abspielen, das einen Pitch hat. Ohne AudioWorklet spielt der Sound ohne Pitch, die Konsole sagt warum.

Grenzen

  • Eine Pitch-Änderung kommt etwa 20 ms später an (ein halbes Frame).
  • Bei exakt 0 Halbtönen gibt das Worklet den Sound unverändert aus. Zurück auf 0 werden die Phasen in etwa einer halben Sekunde nachgezogen, die Tonhöhe schwankt dabei um höchstens ein Fünftel Halbton.
  • Harte Anschläge verschmieren über ein Frame (~43 ms), weit unten wird es zum Brummen, nach oben fällt alles über der halben Abtastrate weg.
  • Rechenzeit: in Node auf stein2 etwa 53 ms pro Sekunde Stereo-Stimme bei +5 (≈ 5 % eines Kerns), bei 0 etwa 5 ms. Im headless Chrome auf melektaus-atx füllen etwa 24 Stereo-Stimmen mit Pitch den Audio-Thread.
  • Ein bestehender Sweep klingt anders als vorher: Der Sound wird beim Tieferziehen nicht mehr länger und kommt daher in derselben Zeit weniger weit.

Tests

pitch-player.test.ts (neu, ersetzt pitch-shift.test.ts) → 11 Tests, grün
sound-library.test.ts                                   → 23 Tests (+4), grün
soundfx.test.ts                                         → 31 Tests (+1), grün
e2e 34 (melektaus-atx)                                  → ALL PASSED – 34 checks (+1)
Gate (melektaus-atx)                                    → CHECK EXIT 0, 242 Testdateien, 3690 Tests
  • Im echten Chrome: Worklet geladen, Pitch-Param gleitet auf 3,0007. Offline gerendert: 440 Hz eine Oktave höher = 880 Hz bei 48000 von 48000 Samples; Sweep 0 → −12 von 392 auf 248 Hz, weiter 48000 Samples.
  • Gegenproben (kein Worklet, shifted am Wert statt am Plan, keine Rückführung auf 0): 15 Tests fallen, Dateien per cmp zurück.
  • Nicht geprüft: Gehört hat niemand etwas, der Browser im Test ist stumm. Der CI-Lauf zu 6e70421 war beim Schließen noch nicht gelesen.
Umgesetzt in `6e70421` („Keep the length of a sound whatever its pitch does #317“), Wiki in `6591188` (*Sounds*, *Architecture*). ## Entscheidungen 1. **Worklet als Abspieler an der Quelle**, nicht als Effekt in der Kette. Es liest den Sound selbst und schaut voraus: Der Ton setzt ohne Latenz mit dem ersten Sample ein, die Wege eines Mix bleiben deckungsgleich. Ein EQ vor dem Pitch wirkt deshalb weiter wie danach. 2. **Phase-Vocoder** statt WSOLA, selbst geschrieben (`src/core/soundfx/pitchPlayer.ts`): Frames von etwa 43 ms, vierfach überlappt, reelle FFT über eine halb so große komplexe. Dazu Phase Locking (Laroche/Dolson) und Zero-phase-Fenster – ohne beides hat ein Ton gegen sich selbst geschwebt und die Lautstärke fiel bis 0,54. 3. **Kein Limit** an Stimmen, gemessen und im Wiki dokumentiert. 4. **Kein engerer Bereich** als die −64 bis +64 des Nodes. ## Was jetzt gilt - Pitch ist ein `AudioParam` in Halbtönen. Er gleitet, rampt und easet wie Gain, und die Länge ändert sich nie mehr – weder beim Start noch mitten im Sound. `length` → `time` reicht damit auch für einen Pitch-Sweep über den ganzen Sound. - Ein Weg ohne Pitch bleibt eine normale `AudioBufferSourceNode`. Die vorab gerechneten WSOLA-Kopien samt Cache (`pitchShift.ts`) sind weg. - Das Worklet (`src/viewport/pitchWorklet.ts`, von Vite über `?worker&url` als eigenes Bundle, 5,8 kB) lädt mit dem ersten Abspielen, das einen Pitch hat. Ohne `AudioWorklet` spielt der Sound ohne Pitch, die Konsole sagt warum. ## Grenzen - Eine Pitch-Änderung kommt etwa **20 ms** später an (ein halbes Frame). - Bei exakt 0 Halbtönen gibt das Worklet den Sound unverändert aus. Zurück auf 0 werden die Phasen in etwa einer halben Sekunde nachgezogen, die Tonhöhe schwankt dabei um höchstens ein Fünftel Halbton. - Harte Anschläge verschmieren über ein Frame (~43 ms), weit unten wird es zum Brummen, nach oben fällt alles über der halben Abtastrate weg. - **Rechenzeit:** in Node auf stein2 etwa 53 ms pro Sekunde Stereo-Stimme bei +5 (≈ 5 % eines Kerns), bei 0 etwa 5 ms. Im headless Chrome auf `melektaus-atx` füllen etwa **24 Stereo-Stimmen** mit Pitch den Audio-Thread. - Ein bestehender Sweep klingt anders als vorher: Der Sound wird beim Tieferziehen nicht mehr länger und kommt daher in derselben Zeit weniger weit. ## Tests ``` pitch-player.test.ts (neu, ersetzt pitch-shift.test.ts) → 11 Tests, grün sound-library.test.ts → 23 Tests (+4), grün soundfx.test.ts → 31 Tests (+1), grün e2e 34 (melektaus-atx) → ALL PASSED – 34 checks (+1) Gate (melektaus-atx) → CHECK EXIT 0, 242 Testdateien, 3690 Tests ``` - Im echten Chrome: Worklet geladen, Pitch-Param gleitet auf 3,0007. Offline gerendert: 440 Hz eine Oktave höher = 880 Hz bei 48000 von 48000 Samples; Sweep 0 → −12 von 392 auf 248 Hz, weiter 48000 Samples. - Gegenproben (kein Worklet, `shifted` am Wert statt am Plan, keine Rückführung auf 0): 15 Tests fallen, Dateien per `cmp` zurück. - **Nicht geprüft:** Gehört hat niemand etwas, der Browser im Test ist stumm. Der CI-Lauf zu `6e70421` war beim Schließen noch nicht gelesen.
admin moved this to Done in Development on 2026-10-06 21:48:18 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: meleki/feuerwerk#317