# Blend two or more Kokoro voicepacks into a new persistent voice. # # Mirrors the wrapper's request-time `voice="a(w)+b(w)"` blend op # (api/src/services/tts_service.py::_get_voices_path) — same math, # but the result is written to /worktank/kokoro/user_voices/ on the # host (bind-mounted into the container at /app/user_voices, which # the compose's VOICES_DIR env points the wrapper at). Result: # - immediately appears in GET /v1/audio/voices # - usable as voice="" in /v1/audio/speech # - persistent across `up --force-recreate` and image upgrade # (it's on the host bind mount, not the container layer) # # Requires the persistent-voices compose layout (entrypoint shim + # VOICES_DIR=/app/user_voices). If the deployed compose predates that, # run `scripts/elway irv-ml1 --playbook playbooks/deploy-kokoro.yaml` # first to roll it out. # # Usage (defaults to athena = af_bella(2)+af_aoede(1) normalized): # scripts/elway irv-ml1 --playbook playbooks/blend-kokoro-voice.yaml # # Override per-run: # scripts/elway irv-ml1 --playbook playbooks/blend-kokoro-voice.yaml \ # --var 'recipe=af_bella(1)+am_adam(1)' --var out_name=androgyne # # Re-run with --var force=1 to overwrite an existing voice. vars: recipe: "af_bella(2)+af_aoede(1)" out_name: athena user_voices_dir: /worktank/kokoro/user_voices container: kokoro script_local: scripts/blend_kokoro_voice.py script_host: /tmp/blend_kokoro_voice.py script_in_container: /tmp/blend_kokoro_voice.py force: "0" api_port: "8193" steps: - name: Upload blend script to host /tmp upload: src: "{{ script_local }}" dest: "{{ script_host }}" mode: "0755" - name: Copy script into kokoro container shell: docker cp {{ script_host }} {{ container }}:{{ script_in_container }} - name: Run blend inside container # `-u 0`: host /worktank/kokoro/user_voices is chowned to 1001:1001 # (appuser) by deploy-kokoro.yaml; we still write as root so the # output is plainly 0644 root:root and the wrapper (running as # appuser) can read it. Also avoids any UID-mapping subtlety. # --force is wired through a shell switch so we can stay inside # elway's plain {{ var }} substitution (no jinja conditionals). shell: | FORCE_FLAG="" if [ "{{ force }}" = "1" ]; then FORCE_FLAG="--force"; fi docker exec -u 0 {{ container }} python {{ script_in_container }} \ --recipe '{{ recipe }}' --out '{{ out_name }}' $FORCE_FLAG when: "[ ! -f {{ user_voices_dir }}/{{ out_name }}.pt ] || [ '{{ force }}' = '1' ]" verify: - name: Output .pt exists on host (persistent) shell: test -s {{ user_voices_dir }}/{{ out_name }}.pt changed_when: "false" - name: Voice appears in /v1/audio/voices shell: curl -sf http://localhost:{{ api_port }}/v1/audio/voices | grep -q '"{{ out_name }}"' changed_when: "false" - name: Synthesize a probe sample (no playback) shell: | curl -sf -X POST http://localhost:{{ api_port }}/v1/audio/speech \ -H 'Content-Type: application/json' \ -d '{"model":"kokoro","input":"Hello, I am {{ out_name }}.","voice":"{{ out_name }}","response_format":"wav"}' \ -o /tmp/{{ out_name }}-probe.wav \ && test -s /tmp/{{ out_name }}-probe.wav changed_when: "false"