← back to Wallpaper Paint Antics

series2/audio.py

65 lines

#!/usr/bin/env python3
"""Local Kokoro narrator, measured caption times, speech-driven mouth envelope, restrained score."""
import json, math, subprocess
from pathlib import Path
import numpy as np
import soundfile as sf
from kokoro_onnx import Kokoro

ROOT=Path(__file__).resolve().parent
CACHE=Path.home()/'.cache/hyperframes/tts'
engine=Kokoro(str(CACHE/'models/kokoro-v1.0.onnx'),str(CACHE/'voices/voices-v1.0.bin'))
episodes=json.loads((ROOT/'episodes.json').read_text())
rate=24000
metadata=[]
for ep_index,ep in enumerate(episodes):
    voice=np.zeros(rate*20,dtype=np.float32)
    segments=[]
    starts=[0.6,5.1,9.6,14.1]
    for i,line in enumerate(ep['lines']):
        output=ROOT/'assets/voice'/f'{ep["slug"]}-{i}.wav'
        if output.exists(): samples,sr=sf.read(output,dtype='float32')
        else:
            samples,sr=engine.create(line,voice='am_michael',speed=1.02,lang='en-us')
            sf.write(output,samples,sr)
        duration=len(samples)/sr
        max_duration=4.22 if i<3 else 4.8
        if duration>max_duration:
            tempo=duration/max_duration
            assert tempo<1.4, (ep['slug'],i,duration)
            fitted=output.with_name(output.stem+'-fit.wav')
            subprocess.run(['ffmpeg','-v','error','-y','-i',str(output),'-af',f'atempo={tempo}','-ar',str(rate),str(fitted)],check=True)
            samples,sr=sf.read(fitted,dtype='float32')
        duration=len(samples)/sr
        assert sr==rate and starts[i]+duration<19.5
        samples=samples*.65/max(.65,float(np.max(np.abs(samples))))
        pos=round(starts[i]*rate);voice[pos:pos+len(samples)]+=samples
        segments.append({'index':i,'start':starts[i],'end':round(starts[i]+duration,3),'text':line,'caption':ep['captions'][i],'duration':round(duration,3)})
        print(ep['slug'],i,round(duration,2),'s',flush=True)
    # A sparse original warm keyboard score, kept well below the spoken voice.
    score=np.zeros(rate*20,dtype=np.float32)
    chords=[[130.813,164.814,195.998,246.942],[110,146.832,174.614,220],[130.813,164.814,195.998,261.626],[97.999,146.832,195.998,246.942]]
    for beat in range(24):
        start=beat*.8
        note=chords[(beat//6+ep_index)%4][beat%4]
        length=min(2.5,20-start)
        if length<=0:continue
        ts=np.arange(int(length*rate))/rate
        tone=(np.sin(2*np.pi*note*ts)+.23*np.sin(2*np.pi*note*2*ts)+.07*np.sin(2*np.pi*note*3*ts))
        env=np.minimum(1,ts/.012)*np.exp(-ts*2.1)*.019
        at=int(start*rate);score[at:at+len(ts)]+=tone*env
    # Measured speech energy also controls mouth openness and dynamic bed ducking.
    energy=[]
    for i in range(1000):
        frame=voice[i*480:(i+1)*480]
        energy.append(float(min(1,np.sqrt(np.mean(frame**2))/.13)))
    duck=np.repeat(1-np.minimum(1,np.array(energy)*3)*.60,480)
    score*=duck
    fade=np.minimum(1,np.arange(rate*20)/(rate*.25))*np.minimum(1,(rate*20-np.arange(rate*20))/(rate*.7))
    mix=(voice+score)*fade
    stereo=np.column_stack((mix,mix))
    sf.write(ROOT/'assets'/f'mix-{ep_index}.wav',stereo,rate,subtype='PCM_16')
    metadata.append({'slug':ep['slug'],'provider':'Kokoro local','voice':'am_michael','duration':20,'segments':segments,'energy':[round(v,4) for v in energy],'peak':float(np.max(np.abs(mix)))})
(ROOT/'audio.json').write_text(json.dumps(metadata,indent=2)+'\n')
print('Five narrated 20-second mixes complete.',flush=True)