Lied 1: Gag mit Stimmen (Lulu fragt, Pino antwortet, Erzähler lässt nachzählen) und Apfelbiss
- gesprochen.ts: Zeilen je Sprache; werkzeuge/liedstimmen.mts erzeugt sie mit ElevenLabs (gleiche Stimmen wie die Folgen) nach public/stimmen/lieder/<lied>/<sprache>/ - Ablauf des Gags richtet sich nach den Aufnahmen (zeiten.ts), Musik leiser beim Sprechen - neues Geräusch apfel_biss Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
31e712a87f
commit
966623c079
8 changed files with 277 additions and 55 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
"hopser": {"sekunden": 1.5, "beschreibung": "Two tiny soft cartoon hops of a small hedgehog, light bouncy boing, cute."},
|
"hopser": {"sekunden": 1.5, "beschreibung": "Two tiny soft cartoon hops of a small hedgehog, light bouncy boing, cute."},
|
||||||
"giraffe_schritte": {"sekunden": 3, "beschreibung": "Slow, big but gentle footsteps of a large animal walking on soft grass, friendly cartoon feel."},
|
"giraffe_schritte": {"sekunden": 3, "beschreibung": "Slow, big but gentle footsteps of a large animal walking on soft grass, friendly cartoon feel."},
|
||||||
"apfel": {"sekunden": 1, "beschreibung": "Crisp snap of an apple stem being picked from a branch, small and clear."},
|
"apfel": {"sekunden": 1, "beschreibung": "Crisp snap of an apple stem being picked from a branch, small and clear."},
|
||||||
|
"apfel_biss": {"sekunden": 1, "beschreibung": "One crisp juicy bite into a fresh apple, cartoon style, short and clear, no voice."},
|
||||||
"strecken": {"sekunden": 2, "beschreibung": "Funny cartoon stretching sound, a long neck bending down slowly, soft comic creak, gentle."},
|
"strecken": {"sekunden": 2, "beschreibung": "Funny cartoon stretching sound, a long neck bending down slowly, soft comic creak, gentle."},
|
||||||
"schnuppern": {"sekunden": 1.5, "beschreibung": "Gentle cartoon sniffing of a flower, two soft sniffs, cute."},
|
"schnuppern": {"sekunden": 1.5, "beschreibung": "Gentle cartoon sniffing of a flower, two soft sniffs, cute."},
|
||||||
"schnarchen": {"sekunden": 3, "beschreibung": "One tiny, cute, soft cartoon snore of a small sleepy hedgehog, with a gentle little whistle on the out-breath."},
|
"schnarchen": {"sekunden": 3, "beschreibung": "One tiny, cute, soft cartoon snore of a small sleepy hedgehog, with a gentle little whistle on the out-breath."},
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,15 @@ import {SCHRIFT} from '../../baukasten/untertitel';
|
||||||
import {plane} from '../../baukasten/zeit';
|
import {plane} from '../../baukasten/zeit';
|
||||||
import {Karaoke} from '../karaoke';
|
import {Karaoke} from '../karaoke';
|
||||||
import {Noten} from '../lied03/Lied03';
|
import {Noten} from '../lied03/Lied03';
|
||||||
import {DAUER, ECHO, LIED, T, ZAHLEN, ZEILEN} from './zeiten';
|
import {DAUER, ECHO, LIED, SPRUCH, T, ZAHLEN, ZEILEN} from './zeiten';
|
||||||
|
|
||||||
// Liedvideo „Eins, zwei – Pino ist dabei!“ (Pinos Liederbuch, Lied 1, Zählen bis 10) – ENTWURF.
|
// Liedvideo „Eins, zwei – Pino ist dabei!“ (Pinos Liederbuch, Lied 1, Zählen bis 10) – ENTWURF.
|
||||||
// Ton je Sprache: Suno-Fassungen auf −14 LUFS in public/musik/lieder/lied01-<sprache>.mp3 (siehe zeiten.ts).
|
// Ton je Sprache: Suno-Fassungen auf −14 LUFS in public/musik/lieder/lied01-<sprache>.mp3 (siehe zeiten.ts).
|
||||||
// Sprache wie bei den Folgen: npx remotion render … Lied01 --props='{"sprache":"es"}'.
|
// Sprache wie bei den Folgen: npx remotion render … Lied01 --props='{"sprache":"es"}'.
|
||||||
// Bei jeder Zahl fliegt ein Apfel vom alten Apfelbaum in Pinos Korb; oben rechts stehen die Zahl
|
// Bei jeder Zahl fliegt ein Apfel vom alten Apfelbaum in Pinos Korb; oben rechts stehen die Zahl
|
||||||
// und die Finger (Daumen = 1, wie in Deutschland üblich; ab 6 ist die erste Hand voll).
|
// und die Finger (Daumen = 1, wie in Deutschland üblich; ab 6 ist die erste Hand voll).
|
||||||
// Im Instrumental holt Lulu sich den obersten Apfel: „Jetzt sind es nur noch …?“ – „Neun!“
|
// Im Instrumental fragt Lulu nach einem Apfel und holt sich den obersten; der Erzähler lässt die Äpfel
|
||||||
// Gesprochenes (ERZ-Ansage, Lulus Frage, Noas Frage, „Mampf!“) fehlt noch; im Entwurf nur als Schrift.
|
// im Korb nachzählen, Countdown, „Neun!“ (gesprochen.ts, Ablauf in zeiten.ts). Noch ohne ERZ-Ansage im Intro.
|
||||||
|
|
||||||
const FPS = 30;
|
const FPS = 30;
|
||||||
const s = (sek: number) => Math.round(sek * FPS);
|
const s = (sek: number) => Math.round(sek * FPS);
|
||||||
|
|
@ -50,9 +50,9 @@ const AM_BAUM: [number, number][] = [
|
||||||
];
|
];
|
||||||
const FLUG = 20; // Frames vom Baum in den Korb
|
const FLUG = 20; // Frames vom Baum in den Korb
|
||||||
|
|
||||||
// Zeitplan des Gags
|
// Zeitplan des Gags (Sekunden in zeiten.ts)
|
||||||
const G = s(T.gag);
|
const G = s(T.gag);
|
||||||
const RUNTER = G + 24;
|
const RUNTER = s(T.runter);
|
||||||
const UNTEN = RUNTER + c9(C9_TIEF - C9_START);
|
const UNTEN = RUNTER + c9(C9_TIEF - C9_START);
|
||||||
const HOCH = UNTEN + 20;
|
const HOCH = UNTEN + 20;
|
||||||
const OBEN = HOCH + c9(C9_TIEF);
|
const OBEN = HOCH + c9(C9_TIEF);
|
||||||
|
|
@ -250,9 +250,9 @@ const Zahltafel: React.FC = () => {
|
||||||
let inhalt: React.ReactNode;
|
let inhalt: React.ReactNode;
|
||||||
if (a.n === '?') {
|
if (a.n === '?') {
|
||||||
// drei Punkte zählen herunter bis zur Antwort
|
// drei Punkte zählen herunter bis zur Antwort
|
||||||
const frei = s(T.antwort) - s(T.frage + 1.2);
|
const frei = s(T.antwort) - s(T.frageEnde);
|
||||||
const punkte = f < s(T.frage + 1.2) ? 3 : Math.max(1, 3 - Math.floor(((f - s(T.frage + 1.2)) / frei) * 3));
|
const punkte = f < s(T.frageEnde) ? 3 : Math.max(1, 3 - Math.floor(((f - s(T.frageEnde)) / frei) * 3));
|
||||||
inhalt = <div style={{fontSize: 150, color: '#b8a68a', width: 330, textAlign: 'center'}}>{f < s(T.frage + 1.2) ? '?' : '•'.repeat(punkte)}</div>;
|
inhalt = <div style={{fontSize: 150, color: '#b8a68a', width: 330, textAlign: 'center'}}>{f < s(T.frageEnde) ? '?' : '•'.repeat(punkte)}</div>;
|
||||||
} else {
|
} else {
|
||||||
const n = a.n;
|
const n = a.n;
|
||||||
inhalt = (
|
inhalt = (
|
||||||
|
|
@ -352,26 +352,6 @@ const Garten: React.FC = () => {
|
||||||
>
|
>
|
||||||
♫ {LIED.titel} ♫
|
♫ {LIED.titel} ♫
|
||||||
</div>
|
</div>
|
||||||
{/* Lulus Frage im Instrumental, solange die Aufnahme fehlt */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 50,
|
|
||||||
left: 60,
|
|
||||||
maxWidth: 1560,
|
|
||||||
padding: '12px 30px 16px',
|
|
||||||
borderRadius: 26,
|
|
||||||
background: 'rgba(251,244,228,0.92)',
|
|
||||||
boxShadow: '0 6px 24px rgba(60,40,20,0.18)',
|
|
||||||
fontFamily: SCHRIFT,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: 46,
|
|
||||||
color: '#4f7a3a',
|
|
||||||
opacity: interpolate(f, [G, G + 8, RUNTER + 40, RUNTER + 50], [0, 1, 1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{LIED.bitte}
|
|
||||||
</div>
|
|
||||||
<Vignette />
|
<Vignette />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -382,6 +362,11 @@ const INTRO_DAUER = s(T.c1 - 0.2);
|
||||||
const INTRO = plane([{id: 'buch', dauer: 1.5}, {id: 't1', dauer: 1.2}, {id: 't2', dauer: 0.5}], {...INTRO_OPTIONEN, vorlauf: 0.2, nachlauf: 0.4});
|
const INTRO = plane([{id: 'buch', dauer: 1.5}, {id: 't1', dauer: 1.2}, {id: 't2', dauer: 0.5}], {...INTRO_OPTIONEN, vorlauf: 0.2, nachlauf: 0.4});
|
||||||
const OUTRO_START = DAUER - OUTRO.dauer;
|
const OUTRO_START = DAUER - OUTRO.dauer;
|
||||||
|
|
||||||
|
// Startzeit je Aufnahme und Musik leiser, solange gesprochen wird
|
||||||
|
const ZEIT = {bitte: 'gag', ja: 'ja', frage: 'frage', antwort: 'antwort', schoen: 'schoen'} as const;
|
||||||
|
const SPRECHEN: [number, number][] = (Object.keys(ZEIT) as (keyof typeof ZEIT)[]).map((id) => [s(T[ZEIT[id]]), s(T[ZEIT[id]] + SPRUCH[id].dauer)]);
|
||||||
|
const musikLaut = (f: number) => (SPRECHEN.some(([a, b]) => f >= a - 6 && f < b + 6) ? 0.45 : 1);
|
||||||
|
|
||||||
export const Lied01: React.FC = () => (
|
export const Lied01: React.FC = () => (
|
||||||
<AbsoluteFill style={{background: '#f4ead8'}}>
|
<AbsoluteFill style={{background: '#f4ead8'}}>
|
||||||
<Sequence durationInFrames={OUTRO_START + 12}>
|
<Sequence durationInFrames={OUTRO_START + 12}>
|
||||||
|
|
@ -399,7 +384,18 @@ export const Lied01: React.FC = () => (
|
||||||
<Outro posen={['pino_A3_lachen', 'noa_A_arm-hoch-winken', 'lulu_A3_lachen']} />
|
<Outro posen={['pino_A3_lachen', 'noa_A_arm-hoch-winken', 'lulu_A3_lachen']} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Karaoke zeilen={ZEILEN} />
|
<Karaoke zeilen={ZEILEN} />
|
||||||
<Audio src={staticFile(LIED.datei)} />
|
<Audio src={staticFile(LIED.datei)} volume={musikLaut} />
|
||||||
|
{(Object.keys(ZEIT) as (keyof typeof ZEIT)[]).map((id) => {
|
||||||
|
const z = SPRUCH[id];
|
||||||
|
return z.datei ? (
|
||||||
|
<Sequence key={id} from={s(T[ZEIT[id]])} durationInFrames={s(z.dauer) + 6}>
|
||||||
|
<Audio src={staticFile(z.datei)} />
|
||||||
|
</Sequence>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
<Sequence from={MAMPF - 4} durationInFrames={30}>
|
||||||
|
<Audio src={staticFile('geraeusche/apfel_biss.mp3')} volume={0.7} />
|
||||||
|
</Sequence>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
32
remotion/src/lieder/lied01/gesprochen.ts
Normal file
32
remotion/src/lieder/lied01/gesprochen.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type {Sprache} from '../../baukasten/sprache';
|
||||||
|
|
||||||
|
// Gesprochene Zeilen im Instrumental von Lied 1 (ElevenLabs, gleiche Stimmen wie in den Folgen).
|
||||||
|
// Erzeugen: npx tsx werkzeuge/liedstimmen.mts lied01 --sprache de (Aufnahmen: stimmen/<sprache>.json)
|
||||||
|
// bitte/ja: Lulu fragt nach einem Apfel – frage/antwort: der Erzähler lässt die Äpfel im Korb zählen,
|
||||||
|
// dazwischen läuft der Countdown – schoen: Lulu kichert.
|
||||||
|
|
||||||
|
export type GesprocheneZeile = {id: 'bitte' | 'ja' | 'frage' | 'antwort' | 'schoen'; wer: 'ERZ' | 'PINO' | 'LULU'; text: string};
|
||||||
|
|
||||||
|
export const GESPROCHEN: Record<Sprache, GesprocheneZeile[]> = {
|
||||||
|
de: [
|
||||||
|
{id: 'bitte', wer: 'LULU', text: 'Darf ich einen Apfel?'},
|
||||||
|
{id: 'ja', wer: 'PINO', text: 'Ja, bitte!'},
|
||||||
|
{id: 'frage', wer: 'ERZ', text: 'Wie viele Äpfel sind jetzt noch im Korb? Zähl mit!'},
|
||||||
|
{id: 'antwort', wer: 'ERZ', text: 'Neun!'},
|
||||||
|
{id: 'schoen', wer: 'LULU', text: 'Neun ist auch schön.'},
|
||||||
|
],
|
||||||
|
es: [
|
||||||
|
{id: 'bitte', wer: 'LULU', text: '¿Puedo comer una manzana?'},
|
||||||
|
{id: 'ja', wer: 'PINO', text: '¡Sí, por favor!'},
|
||||||
|
{id: 'frage', wer: 'ERZ', text: '¿Cuántas manzanas quedan en la cesta? ¡Cuenta conmigo!'},
|
||||||
|
{id: 'antwort', wer: 'ERZ', text: '¡Nueve!'},
|
||||||
|
{id: 'schoen', wer: 'LULU', text: 'Nueve también es bonito.'},
|
||||||
|
],
|
||||||
|
fr: [
|
||||||
|
{id: 'bitte', wer: 'LULU', text: 'Je peux avoir une pomme ?'},
|
||||||
|
{id: 'ja', wer: 'PINO', text: 'Oui, bien sûr !'},
|
||||||
|
{id: 'frage', wer: 'ERZ', text: 'Combien de pommes reste-t-il dans le panier ? Compte avec moi !'},
|
||||||
|
{id: 'antwort', wer: 'ERZ', text: 'Neuf !'},
|
||||||
|
{id: 'schoen', wer: 'LULU', text: "Neuf, c'est bien aussi."},
|
||||||
|
],
|
||||||
|
};
|
||||||
27
remotion/src/lieder/lied01/stimmen/de.json
Normal file
27
remotion/src/lieder/lied01/stimmen/de.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"bitte": {
|
||||||
|
"datei": "stimmen/lieder/lied01/de/bitte.mp3",
|
||||||
|
"dauer": 1.8,
|
||||||
|
"text": "Darf ich einen Apfel?"
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"datei": "stimmen/lieder/lied01/de/ja.mp3",
|
||||||
|
"dauer": 1.31,
|
||||||
|
"text": "Ja, bitte!"
|
||||||
|
},
|
||||||
|
"frage": {
|
||||||
|
"datei": "stimmen/lieder/lied01/de/frage.mp3",
|
||||||
|
"dauer": 3.87,
|
||||||
|
"text": "Wie viele Äpfel sind jetzt noch im Korb? Zähl mit!"
|
||||||
|
},
|
||||||
|
"antwort": {
|
||||||
|
"datei": "stimmen/lieder/lied01/de/antwort.mp3",
|
||||||
|
"dauer": 0.99,
|
||||||
|
"text": "Neun!"
|
||||||
|
},
|
||||||
|
"schoen": {
|
||||||
|
"datei": "stimmen/lieder/lied01/de/schoen.mp3",
|
||||||
|
"dauer": 1.65,
|
||||||
|
"text": "Neun ist auch schön."
|
||||||
|
}
|
||||||
|
}
|
||||||
27
remotion/src/lieder/lied01/stimmen/es.json
Normal file
27
remotion/src/lieder/lied01/stimmen/es.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"bitte": {
|
||||||
|
"datei": "stimmen/lieder/lied01/es/bitte.mp3",
|
||||||
|
"dauer": 1.96,
|
||||||
|
"text": "¿Puedo comer una manzana?"
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"datei": "stimmen/lieder/lied01/es/ja.mp3",
|
||||||
|
"dauer": 1.65,
|
||||||
|
"text": "¡Sí, por favor!"
|
||||||
|
},
|
||||||
|
"frage": {
|
||||||
|
"datei": "stimmen/lieder/lied01/es/frage.mp3",
|
||||||
|
"dauer": 3.87,
|
||||||
|
"text": "¿Cuántas manzanas quedan en la cesta? ¡Cuenta conmigo!"
|
||||||
|
},
|
||||||
|
"antwort": {
|
||||||
|
"datei": "stimmen/lieder/lied01/es/antwort.mp3",
|
||||||
|
"dauer": 0.99,
|
||||||
|
"text": "¡Nueve!"
|
||||||
|
},
|
||||||
|
"schoen": {
|
||||||
|
"datei": "stimmen/lieder/lied01/es/schoen.mp3",
|
||||||
|
"dauer": 2.04,
|
||||||
|
"text": "Nueve también es bonito."
|
||||||
|
}
|
||||||
|
}
|
||||||
27
remotion/src/lieder/lied01/stimmen/fr.json
Normal file
27
remotion/src/lieder/lied01/stimmen/fr.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"bitte": {
|
||||||
|
"datei": "stimmen/lieder/lied01/fr/bitte.mp3",
|
||||||
|
"dauer": 1.57,
|
||||||
|
"text": "Je peux avoir une pomme ?"
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"datei": "stimmen/lieder/lied01/fr/ja.mp3",
|
||||||
|
"dauer": 1.65,
|
||||||
|
"text": "Oui, bien sûr !"
|
||||||
|
},
|
||||||
|
"frage": {
|
||||||
|
"datei": "stimmen/lieder/lied01/fr/frage.mp3",
|
||||||
|
"dauer": 3.47,
|
||||||
|
"text": "Combien de pommes reste-t-il dans le panier ? Compte avec moi !"
|
||||||
|
},
|
||||||
|
"antwort": {
|
||||||
|
"datei": "stimmen/lieder/lied01/fr/antwort.mp3",
|
||||||
|
"dauer": 0.91,
|
||||||
|
"text": "Neuf !"
|
||||||
|
},
|
||||||
|
"schoen": {
|
||||||
|
"datei": "stimmen/lieder/lied01/fr/schoen.mp3",
|
||||||
|
"dauer": 1.96,
|
||||||
|
"text": "Neuf, c'est bien aussi."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import {SPRACHE, type Sprache} from '../../baukasten/sprache';
|
import {SPRACHE, type Sprache} from '../../baukasten/sprache';
|
||||||
import type {LiedZeile} from '../karaoke';
|
import type {LiedZeile} from '../karaoke';
|
||||||
|
import {GESPROCHEN, type GesprocheneZeile} from './gesprochen';
|
||||||
|
import STIMMEN_DE from './stimmen/de.json';
|
||||||
|
import STIMMEN_ES from './stimmen/es.json';
|
||||||
|
import STIMMEN_FR from './stimmen/fr.json';
|
||||||
|
|
||||||
// Zeiten (Sekunden) der Suno-Fassungen von Lied 1, je Sprache. Am 05.11.2026 mit faster-whisper
|
// Zeiten (Sekunden) der Suno-Fassungen von Lied 1, je Sprache. Am 05.11.2026 mit faster-whisper
|
||||||
// (Modell medium, Wortzeiten) gemessen. Whisper legt das erste Wort einer Zeile oft zu früh (es nimmt
|
// (Modell medium, Wortzeiten) gemessen. Whisper legt das erste Wort einer Zeile oft zu früh (es nimmt
|
||||||
// die Pause davor mit); dort gilt deshalb Wortende − 0,38 s. Am Lied 3 gegen Sunos aligned_lyrics
|
// die Pause davor mit); dort gilt deshalb Wortende − 0,38 s. Am Lied 3 gegen Sunos aligned_lyrics
|
||||||
// geprüft: etwa ±0,15 s. Wo Whisper Wörter verschluckt hat, sind sie mit „~“ aus dem ersten Refrain
|
// geprüft: etwa ±0,15 s. Wo Whisper Wörter verschluckt hat, sind sie mit „~“ aus dem ersten Refrain
|
||||||
// abgeleitet (gleiche Melodie). Die Fassungen singen ohne Pausen zum Nachsingen; „1 … 10“ kommt in
|
// abgeleitet (gleiche Melodie). Die Fassungen singen ohne Pausen zum Nachsingen; „1 … 10“ kommt in
|
||||||
// einem Zug. Danach rund 20 s Instrumental: dort liegt der Lulu-Gag (Stimmen fehlen noch).
|
// einem Zug. Danach rund 20 s Instrumental: dort liegt der Lulu-Gag mit gesprochenen Zeilen
|
||||||
|
// (gesprochen.ts, Aufnahmen in stimmen/<sprache>.json); sein Ablauf richtet sich nach deren Länge.
|
||||||
|
|
||||||
type Abschnitte = {
|
type Abschnitte = {
|
||||||
c1: number; // Refrain 1
|
c1: number; // Refrain 1
|
||||||
|
|
@ -15,9 +20,6 @@ type Abschnitte = {
|
||||||
v2: number; // Strophe 2 (6–10)
|
v2: number; // Strophe 2 (6–10)
|
||||||
echo: number; // Zählen 1–10 in einem Zug
|
echo: number; // Zählen 1–10 in einem Zug
|
||||||
echoEnde: number;
|
echoEnde: number;
|
||||||
gag: number; // Instrumental: Lulu holt sich einen Apfel
|
|
||||||
frage: number; // „Jetzt sind es nur noch …?“
|
|
||||||
antwort: number; // „Neun!“
|
|
||||||
c3: number; // Schluss-Refrain
|
c3: number; // Schluss-Refrain
|
||||||
ende: number; // Nachspiel (Glockenspiel)
|
ende: number; // Nachspiel (Glockenspiel)
|
||||||
};
|
};
|
||||||
|
|
@ -31,22 +33,14 @@ type LiedDaten = {
|
||||||
zeilen: LiedZeile[];
|
zeilen: LiedZeile[];
|
||||||
titel: string; // Liedtitel im ersten Refrain
|
titel: string; // Liedtitel im ersten Refrain
|
||||||
buchTitel: string; // Buchseite im Intro
|
buchTitel: string; // Buchseite im Intro
|
||||||
bitte: string; // Lulus Frage und Pinos Antwort im Instrumental (bis es Aufnahmen gibt)
|
|
||||||
mampf: string;
|
mampf: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// gesprochene Zeile ohne gemessene Wortzeiten: Wörter gleichmäßig verteilt
|
|
||||||
const gesprochen = (text: string, start: number, proWort = 0.28) =>
|
|
||||||
text
|
|
||||||
.split(' ')
|
|
||||||
.map((w, i) => `${w}@${(start + i * proWort).toFixed(2)}`)
|
|
||||||
.join(' ');
|
|
||||||
|
|
||||||
// Suno-Fassung A (77404860-d5d7-43ca-8c06-a8347b5f7282, lied01-zaehlen.wav, 1:33)
|
// Suno-Fassung A (77404860-d5d7-43ca-8c06-a8347b5f7282, lied01-zaehlen.wav, 1:33)
|
||||||
const DE: LiedDaten = {
|
const DE: LiedDaten = {
|
||||||
datei: 'musik/lieder/lied01-de.mp3',
|
datei: 'musik/lieder/lied01-de.mp3',
|
||||||
sekunden: 93.24,
|
sekunden: 93.24,
|
||||||
T: {c1: 5.52, v1: 17.26, c2: 26.92, v2: 36.4, echo: 45.78, echoEnde: 51.52, gag: 52.6, frage: 62.4, antwort: 65.6, c3: 70.92, ende: 80.1},
|
T: {c1: 5.52, v1: 17.26, c2: 26.92, v2: 36.4, echo: 45.78, echoEnde: 51.52, c3: 70.92, ende: 80.1},
|
||||||
zahlen: [17.26, 17.64, 19.56, 19.94, 21.96, 36.4, 36.78, 38.58, 39.06, 40.96],
|
zahlen: [17.26, 17.64, 19.56, 19.94, 21.96, 36.4, 36.78, 38.58, 39.06, 40.96],
|
||||||
echo: [45.78, 46.16, 46.84, 47.22, 48.04, 48.56, 49.16, 50.02, 50.44, 50.9],
|
echo: [45.78, 46.16, 46.84, 47.22, 48.04, 48.56, 49.16, 50.02, 50.44, 50.9],
|
||||||
zeilen: [
|
zeilen: [
|
||||||
|
|
@ -68,8 +62,6 @@ const DE: LiedDaten = {
|
||||||
'zehn@43.68 Äpfel_–@43.88 ganz,@44.52 ganz@44.84 toll!@45.08',
|
'zehn@43.68 Äpfel_–@43.88 ganz,@44.52 ganz@44.84 toll!@45.08',
|
||||||
'Eins!@45.78 Zwei!@46.16 Drei!@46.84 Vier!@47.22 Fünf!@48.04',
|
'Eins!@45.78 Zwei!@46.16 Drei!@46.84 Vier!@47.22 Fünf!@48.04',
|
||||||
'Sechs!@48.56 Sieben!@49.16 Acht!@50.02 Neun!@50.44 Zehn!@50.9',
|
'Sechs!@48.56 Sieben!@49.16 Acht!@50.02 Neun!@50.44 Zehn!@50.9',
|
||||||
gesprochen('Jetzt sind es nur noch_…?', 62.4),
|
|
||||||
'Neun!@65.6',
|
|
||||||
'Zähl@70.92 mit,@71.3 zähl@71.98 mit,@72.32',
|
'Zähl@70.92 mit,@71.3 zähl@71.98 mit,@72.32',
|
||||||
'Finger@73.12 hoch@73.38 und@73.76 mach@73.9 mit!@74.18',
|
'Finger@73.12 hoch@73.38 und@73.76 mach@73.9 mit!@74.18',
|
||||||
'Eins@75.48 bis@75.66 zehn,@75.92 das@76.22 schaffen@76.54 wir,@76.94',
|
'Eins@75.48 bis@75.66 zehn,@75.92 das@76.22 schaffen@76.54 wir,@76.94',
|
||||||
|
|
@ -77,7 +69,6 @@ const DE: LiedDaten = {
|
||||||
],
|
],
|
||||||
titel: 'Eins, zwei – Pino ist dabei!',
|
titel: 'Eins, zwei – Pino ist dabei!',
|
||||||
buchTitel: 'Eins, zwei –\nPino ist dabei!',
|
buchTitel: 'Eins, zwei –\nPino ist dabei!',
|
||||||
bitte: 'Lulu: „Darf ich einen Apfel?“ – Pino: „Ja, bitte!“',
|
|
||||||
mampf: 'Mampf!',
|
mampf: 'Mampf!',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -85,7 +76,7 @@ const DE: LiedDaten = {
|
||||||
const ES: LiedDaten = {
|
const ES: LiedDaten = {
|
||||||
datei: 'musik/lieder/lied01-es.mp3',
|
datei: 'musik/lieder/lied01-es.mp3',
|
||||||
sekunden: 101.96,
|
sekunden: 101.96,
|
||||||
T: {c1: 10.58, v1: 19.56, c2: 29.6, v2: 38.88, echo: 48.58, echoEnde: 57.2, gag: 58.3, frage: 67.4, antwort: 70.6, c3: 77.78, ende: 86.0},
|
T: {c1: 10.58, v1: 19.56, c2: 29.6, v2: 38.88, echo: 48.58, echoEnde: 57.2, c3: 77.78, ende: 86.0},
|
||||||
zahlen: [19.56, 20.02, 22.1, 22.5, 24.72, 38.88, 39.26, 41.2, 41.6, 43.88],
|
zahlen: [19.56, 20.02, 22.1, 22.5, 24.72, 38.88, 39.26, 41.2, 41.6, 43.88],
|
||||||
echo: [48.58, 49.42, 50.74, 51.38, 51.88, 53.6, 54.38, 55.6, 56.14, 56.66],
|
echo: [48.58, 49.42, 50.74, 51.38, 51.88, 53.6, 54.38, 55.6, 56.14, 56.66],
|
||||||
zeilen: [
|
zeilen: [
|
||||||
|
|
@ -107,8 +98,6 @@ const ES: LiedDaten = {
|
||||||
'diez@45.82 manzanas:@46.2 ¡gran@47.5 fiesta!@47.66',
|
'diez@45.82 manzanas:@46.2 ¡gran@47.5 fiesta!@47.66',
|
||||||
'¡Uno!@48.58 ¡Dos!@49.42 ¡Tres!@50.74 ¡Cuatro!@51.38 ¡Cinco!@51.88',
|
'¡Uno!@48.58 ¡Dos!@49.42 ¡Tres!@50.74 ¡Cuatro!@51.38 ¡Cinco!@51.88',
|
||||||
'¡Seis!@53.6 ¡Siete!@54.38 ¡Ocho!@55.6 ¡Nueve!@56.14 ¡Diez!@56.66',
|
'¡Seis!@53.6 ¡Siete!@54.38 ¡Ocho!@55.6 ¡Nueve!@56.14 ¡Diez!@56.66',
|
||||||
gesprochen('Ahora solo quedan_…?', 67.4),
|
|
||||||
'¡Nueve!@70.6',
|
|
||||||
// Schluss-Refrain: Whisper hat die erste Zeile zusammengezogen; ~ wie Refrain 1 (+67,2 s)
|
// Schluss-Refrain: Whisper hat die erste Zeile zusammengezogen; ~ wie Refrain 1 (+67,2 s)
|
||||||
'¡Cuenta,@77.78 cuenta,@78.42',
|
'¡Cuenta,@77.78 cuenta,@78.42',
|
||||||
'dedos@79.6 arriba@79.94 y@80.46 ven!@80.72',
|
'dedos@79.6 arriba@79.94 y@80.46 ven!@80.72',
|
||||||
|
|
@ -117,7 +106,6 @@ const ES: LiedDaten = {
|
||||||
],
|
],
|
||||||
titel: 'Uno, dos: ¡Pino llegó!',
|
titel: 'Uno, dos: ¡Pino llegó!',
|
||||||
buchTitel: 'Uno, dos:\n¡Pino llegó!',
|
buchTitel: 'Uno, dos:\n¡Pino llegó!',
|
||||||
bitte: 'Lulu: «¿Puedo comer una manzana?» – Pino: «¡Sí, por favor!»',
|
|
||||||
mampf: '¡Ñam!',
|
mampf: '¡Ñam!',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -125,7 +113,7 @@ const ES: LiedDaten = {
|
||||||
const FR: LiedDaten = {
|
const FR: LiedDaten = {
|
||||||
datei: 'musik/lieder/lied01-fr.mp3',
|
datei: 'musik/lieder/lied01-fr.mp3',
|
||||||
sekunden: 92.0,
|
sekunden: 92.0,
|
||||||
T: {c1: 10.66, v1: 19.74, c2: 29.28, v2: 38.6, echo: 48.36, echoEnde: 53.7, gag: 54.8, frage: 64.2, antwort: 67.4, c3: 73.0, ende: 81.0},
|
T: {c1: 10.66, v1: 19.74, c2: 29.28, v2: 38.6, echo: 48.36, echoEnde: 53.7, c3: 73.0, ende: 81.0},
|
||||||
zahlen: [19.74, 19.96, 21.9, 22.28, 24.16, 38.6, 38.96, 41.08, 41.48, 43.38],
|
zahlen: [19.74, 19.96, 21.9, 22.28, 24.16, 38.6, 38.96, 41.08, 41.48, 43.38],
|
||||||
echo: [48.36, 48.76, 49.32, 49.84, 50.56, 51.18, 51.7, 52.0, 52.3, 52.84],
|
echo: [48.36, 48.76, 49.32, 49.84, 50.56, 51.18, 51.7, 52.0, 52.3, 52.84],
|
||||||
zeilen: [
|
zeilen: [
|
||||||
|
|
@ -147,8 +135,6 @@ const FR: LiedDaten = {
|
||||||
"dix@45.38 pommes_–@45.74 c'est@46.32 super_!@46.66",
|
"dix@45.38 pommes_–@45.74 c'est@46.32 super_!@46.66",
|
||||||
'Un_!@48.36 Deux_!@48.76 Trois_!@49.32 Quatre_!@49.84 Cinq_!@50.56',
|
'Un_!@48.36 Deux_!@48.76 Trois_!@49.32 Quatre_!@49.84 Cinq_!@50.56',
|
||||||
'Six_!@51.18 Sept_!@51.7 Huit_!@52 Neuf_!@52.3 Dix_!@52.84', // ~ „huit“ zwischen sept und neuf
|
'Six_!@51.18 Sept_!@51.7 Huit_!@52 Neuf_!@52.3 Dix_!@52.84', // ~ „huit“ zwischen sept und neuf
|
||||||
gesprochen('Maintenant, il en reste_…_?', 64.2),
|
|
||||||
'Neuf_!@67.4',
|
|
||||||
'Comptons,@73 comptons,@73.38',
|
'Comptons,@73 comptons,@73.38',
|
||||||
"les@74.2 doigts@74.42 en@74.76 l'air,@75.14 allons_!@75.68",
|
"les@74.2 doigts@74.42 en@74.76 l'air,@75.14 allons_!@75.68",
|
||||||
'Un@76.34 à@76.62 dix,@76.94 oui,@77.36 on@77.58 y@77.74 croit,@77.98',
|
'Un@76.34 à@76.62 dix,@76.94 oui,@77.36 on@77.58 y@77.74 croit,@77.98',
|
||||||
|
|
@ -156,14 +142,53 @@ const FR: LiedDaten = {
|
||||||
],
|
],
|
||||||
titel: 'Un, deux – Pino vient aussi !',
|
titel: 'Un, deux – Pino vient aussi !',
|
||||||
buchTitel: 'Un, deux –\nPino vient aussi !',
|
buchTitel: 'Un, deux –\nPino vient aussi !',
|
||||||
bitte: 'Lulu : « Je peux avoir une pomme ? » – Pino : « Oui, bien sûr ! »',
|
|
||||||
mampf: 'Miam !',
|
mampf: 'Miam !',
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIEDER: Record<Sprache, LiedDaten> = {de: DE, es: ES, fr: FR};
|
const LIEDER: Record<Sprache, LiedDaten> = {de: DE, es: ES, fr: FR};
|
||||||
export const LIED = LIEDER[SPRACHE];
|
export const LIED = LIEDER[SPRACHE];
|
||||||
export const T = LIED.T;
|
|
||||||
|
// Gesprochene Zeilen im Instrumental: Aufnahme (falls vorhanden) und Länge
|
||||||
|
type Aufnahme = {datei: string; dauer: number; text: string};
|
||||||
|
const AUFNAHMEN = ({de: STIMMEN_DE, es: STIMMEN_ES, fr: STIMMEN_FR} as Record<Sprache, Record<string, Aufnahme>>)[SPRACHE];
|
||||||
|
type Spruch = GesprocheneZeile & {datei?: string; dauer: number};
|
||||||
|
export const SPRUCH = {} as Record<GesprocheneZeile['id'], Spruch>;
|
||||||
|
for (const z of GESPROCHEN[SPRACHE]) {
|
||||||
|
const a = AUFNAHMEN[z.id];
|
||||||
|
// ohne passende Aufnahme: geschätzte Länge, keine Datei
|
||||||
|
SPRUCH[z.id] = {...z, datei: a?.text === z.text ? a.datei : undefined, dauer: a?.text === z.text ? a.dauer : 0.5 + z.text.length * 0.06};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ablauf des Gags (Sekunden): Lulu fragt, Pino sagt ja und Lulu senkt dabei schon den Hals,
|
||||||
|
// holt sich den obersten Apfel und hebt den Kopf (5,1 s, siehe Lied01.tsx: c9(58) + 20 + c9(75) Frames),
|
||||||
|
// „Mampf!“, der Erzähler fragt nach den Äpfeln im Korb, Countdown, „Neun!“, Lulu kichert.
|
||||||
|
const A = LIED.T;
|
||||||
|
const bitte = A.echoEnde + 0.5;
|
||||||
|
const ja = bitte + SPRUCH.bitte.dauer + 0.15;
|
||||||
|
const runter = ja + 0.3;
|
||||||
|
const mampf = runter + 5.1 + 0.27;
|
||||||
|
const frage = mampf + 0.6;
|
||||||
|
const frageEnde = frage + SPRUCH.frage.dauer;
|
||||||
|
const antwort = frageEnde + 2.4; // Countdown zum Mitzählen
|
||||||
|
const schoen = antwort + SPRUCH.antwort.dauer + 0.3;
|
||||||
|
if (schoen + SPRUCH.schoen.dauer > A.c3 - 0.3) throw new Error(`Lied 1 (${SPRACHE}): Gag zu lang für das Instrumental`);
|
||||||
|
export const T = {...A, gag: bitte, ja, runter, mampf, frage, frageEnde, antwort, schoen};
|
||||||
|
|
||||||
|
// gesprochene Zeile im Mitlesetext: Wörter gleichmäßig über die Aufnahme verteilt
|
||||||
|
const mitlesen = (text: string, start: number, dauer: number) => {
|
||||||
|
const w = text.split(' ');
|
||||||
|
return w.map((x, i) => `${x.replace(/ /g, '_')}@${(start + (i * dauer * 0.85) / w.length).toFixed(2)}`).join(' ');
|
||||||
|
};
|
||||||
|
const GESPROCHENE_ZEILEN: [number, LiedZeile][] = [
|
||||||
|
[bitte, mitlesen(SPRUCH.bitte.text, bitte, SPRUCH.bitte.dauer)],
|
||||||
|
[ja, mitlesen(SPRUCH.ja.text, ja, SPRUCH.ja.dauer)],
|
||||||
|
[frage, mitlesen(SPRUCH.frage.text, frage, SPRUCH.frage.dauer)],
|
||||||
|
[antwort, mitlesen(SPRUCH.antwort.text, antwort, SPRUCH.antwort.dauer)],
|
||||||
|
[schoen, mitlesen(SPRUCH.schoen.text, schoen, SPRUCH.schoen.dauer)],
|
||||||
|
];
|
||||||
export const ZAHLEN = LIED.zahlen;
|
export const ZAHLEN = LIED.zahlen;
|
||||||
export const ECHO = LIED.echo;
|
export const ECHO = LIED.echo;
|
||||||
export const ZEILEN = LIED.zeilen;
|
export const ZEILEN = [...LIED.zeilen.map((z) => [Number(z.split(' ')[0].split('@')[1]), z] as [number, LiedZeile]), ...GESPROCHENE_ZEILEN]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([, z]) => z);
|
||||||
export const DAUER = Math.round(LIED.sekunden * 30);
|
export const DAUER = Math.round(LIED.sekunden * 30);
|
||||||
|
|
|
||||||
87
remotion/werkzeuge/liedstimmen.mts
Normal file
87
remotion/werkzeuge/liedstimmen.mts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
// Erzeugt die gesprochenen Zeilen eines Liedvideos mit ElevenLabs (gleiche Stimmen und
|
||||||
|
// Einstellungen wie werkzeuge/stimmen.mts für die Folgen) und trägt ihre Länge in
|
||||||
|
// src/lieder/<lied>/stimmen/<sprache>.json ein. Die Zeilen stehen in src/lieder/<lied>/gesprochen.ts.
|
||||||
|
//
|
||||||
|
// npx tsx werkzeuge/liedstimmen.mts lied01 --sprache es
|
||||||
|
// … --neu alle Zeilen neu erzeugen
|
||||||
|
// … --probe nur anzeigen, was erzeugt würde (kostet nichts)
|
||||||
|
//
|
||||||
|
// Schlüssel aus ELEVENLABS_API_KEY bzw. youtube/.env.local (nicht im Git).
|
||||||
|
// Aufnahmen: public/stimmen/lieder/<lied>/<sprache>/<id>.mp3 (nicht im Git; auf dem VPS
|
||||||
|
// liegt public/stimmen als Link auf /shared/youtube/stimmen/).
|
||||||
|
|
||||||
|
import {spawnSync} from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
import {Readable} from 'node:stream';
|
||||||
|
import {ElevenLabsClient} from '@elevenlabs/elevenlabs-js';
|
||||||
|
|
||||||
|
const WURZEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
process.chdir(WURZEL);
|
||||||
|
const envDatei = path.join(WURZEL, '..', '.env.local');
|
||||||
|
if (!process.env.ELEVENLABS_API_KEY && fs.existsSync(envDatei)) process.loadEnvFile(envDatei);
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const lied = args.find((a) => !a.startsWith('--') && !['de', 'es', 'fr'].includes(a));
|
||||||
|
const wert = (n: string) => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined);
|
||||||
|
const sprache = (wert('--sprache') ?? 'de') as 'de' | 'es' | 'fr';
|
||||||
|
const neu = args.includes('--neu');
|
||||||
|
const probe = args.includes('--probe');
|
||||||
|
if (!lied) throw new Error('Lied angeben, z. B. lied01');
|
||||||
|
if (!['de', 'es', 'fr'].includes(sprache)) throw new Error(`Unbekannte Sprache: ${sprache}`);
|
||||||
|
|
||||||
|
const cfg = JSON.parse(fs.readFileSync('werkzeuge/stimmen.config.json', 'utf8')) as {
|
||||||
|
modell: string;
|
||||||
|
format: string;
|
||||||
|
stimmen: Record<string, string>;
|
||||||
|
sprachen?: Record<string, {stimmen?: Record<string, string>}>;
|
||||||
|
einstellungen?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
if (!process.env.ELEVENLABS_API_KEY && !probe) throw new Error('ELEVENLABS_API_KEY ist nicht gesetzt (siehe youtube/.env.local).');
|
||||||
|
const client = probe ? null : new ElevenLabsClient();
|
||||||
|
|
||||||
|
const {GESPROCHEN} = (await import(`../src/lieder/${lied}/gesprochen.ts`)) as {GESPROCHEN: Record<string, {id: string; wer: string; text: string}[]>};
|
||||||
|
const jsonDatei = `src/lieder/${lied}/stimmen/${sprache}.json`;
|
||||||
|
const aufnahmen: Record<string, {datei: string; dauer: number; text: string}> = fs.existsSync(jsonDatei) ? JSON.parse(fs.readFileSync(jsonDatei, 'utf8')) : {};
|
||||||
|
const ordner = `public/stimmen/lieder/${lied}/${sprache}`;
|
||||||
|
fs.mkdirSync(ordner, {recursive: true});
|
||||||
|
|
||||||
|
const stimmeVon = (wer: string): string | undefined => {
|
||||||
|
const v = cfg.sprachen?.[sprache]?.stimmen?.[wer] || cfg.stimmen[wer];
|
||||||
|
if (!v) return undefined;
|
||||||
|
return v in cfg.stimmen ? stimmeVon(v) : v;
|
||||||
|
};
|
||||||
|
const laenge = (datei: string) => {
|
||||||
|
const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', datei], {encoding: 'utf8'});
|
||||||
|
return Math.round(parseFloat(r.stdout.trim()) * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
let erzeugt = 0;
|
||||||
|
for (const z of GESPROCHEN[sprache]) {
|
||||||
|
const datei = `${z.id}.mp3`;
|
||||||
|
const pfad = path.join(ordner, datei);
|
||||||
|
const alt = aufnahmen[z.id];
|
||||||
|
if (!neu && alt && alt.text === z.text && fs.existsSync(pfad)) continue;
|
||||||
|
const stimme = stimmeVon(z.wer);
|
||||||
|
if (!stimme) throw new Error(`Keine Stimme für „${z.wer}“ in stimmen.config.json`);
|
||||||
|
if (probe) {
|
||||||
|
console.log(`~ ${z.id} (${z.wer}): ${z.text}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const audio = await client!.textToSpeech.convert(stimme, {
|
||||||
|
text: z.text,
|
||||||
|
modelId: cfg.modell,
|
||||||
|
languageCode: sprache,
|
||||||
|
outputFormat: cfg.format as never,
|
||||||
|
voiceSettings: {stability: cfg.einstellungen?.stability as number, similarityBoost: cfg.einstellungen?.similarity_boost as number},
|
||||||
|
});
|
||||||
|
const teile: Buffer[] = [];
|
||||||
|
for await (const t of Readable.fromWeb(audio as never)) teile.push(Buffer.from(t));
|
||||||
|
fs.writeFileSync(pfad, Buffer.concat(teile));
|
||||||
|
aufnahmen[z.id] = {datei: `stimmen/lieder/${lied}/${sprache}/${datei}`, dauer: laenge(pfad), text: z.text};
|
||||||
|
fs.writeFileSync(jsonDatei, JSON.stringify(aufnahmen, null, '\t') + '\n');
|
||||||
|
erzeugt++;
|
||||||
|
console.log(`✓ ${z.id} (${z.wer}) ${aufnahmen[z.id].dauer} s: ${z.text}`);
|
||||||
|
}
|
||||||
|
console.log(`\n${probe ? 'Probe' : `Erzeugt: ${erzeugt} Zeilen`}`);
|
||||||
Loading…
Reference in a new issue