aioli-cli 0.1.35__tar.gz → 0.1.37__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/PKG-INFO +1 -1
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/pyproject.toml +6 -1
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/models_data.py +2 -2
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/cast.py +30 -5
- aioli_cli-0.1.37/src/aioli_podcast/cli.py +118 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/episode.py +117 -19
- aioli_cli-0.1.37/src/aioli_podcast/feed.py +189 -0
- aioli_cli-0.1.35/src/aioli_podcast/cli.py +0 -60
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/.gitignore +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/LICENSE +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/README.md +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/_plugins.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/fetch_ui.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/main.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/readiness.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/registry.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/setup.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/status.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/advise.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/config.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/doctor.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/fetch.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/probe.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/readiness.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/cli.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/companions.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/config.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/data/models.toml +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/doctor.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/gpu.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/launcher.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/models.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/models_data.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/plugin.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/presets.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/stub.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/ui.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_master/cli.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_master/doctor.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_master/ops.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_master/plugin.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/acestep.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/base.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/llm_caption.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/minimax_comfy.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/process.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/stub.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/yue2.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/backend_cmd.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/generate_cmd.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/inspire_cmd.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/models_cmd.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/client.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/config.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/config_data/default.toml +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/formats.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/generate.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/jobs.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/layout.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/doctor.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/generator.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/history.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/jsonstore.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/models.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/paths.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/plugin.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/presets.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/prompts.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/server.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/transport/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/transport/client.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/tui.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/doctor.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/plugin.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/script.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/cli.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/doctor.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/models_data.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/paths.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/plugin.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/stub.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/wgp.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/worker.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/doctor.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/engines_cmd.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/generate.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/models_cmd.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/profiles.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/config/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/config/settings.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/generation.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/model_manager.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/profiles.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/registry.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/types.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/devices.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/doctor.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/__init__.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/chatterbox.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/kokoro.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/moss.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/piper.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/qwen3.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/stub.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/voxcpm.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/xtts.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/models_data.py +0 -0
- {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/plugin.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "aioli-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.37"
|
|
4
4
|
description = "aioli — local-first AI media suite CLI (images, video, music, voice, podcast)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -112,6 +112,11 @@ testpaths = [
|
|
|
112
112
|
"plugins/master/tests",
|
|
113
113
|
]
|
|
114
114
|
addopts = "-q"
|
|
115
|
+
# Markers: "network" sube a upstream real (gasta red y disco). Opt-in via
|
|
116
|
+
# AIOLI_NET_TESTS=1, lo activa el job smoke-bundles de release.yml.
|
|
117
|
+
markers = [
|
|
118
|
+
"network: baja del asset real de upstream (gasta red); opt-in",
|
|
119
|
+
]
|
|
115
120
|
|
|
116
121
|
[dependency-groups]
|
|
117
122
|
dev = [
|
|
@@ -73,8 +73,8 @@ MODELS: list[dict] = [
|
|
|
73
73
|
"name": "audiocpp_cli runtime (linux x86_64 CUDA, motor yue2)",
|
|
74
74
|
"rel": "audiocpp/audiocpp_cli",
|
|
75
75
|
"url": "https://github.com/damenordev/aioli-runtimes/releases/download/runtimes-v4/audiocpp-linux-x86_64-cuda.tar.gz",
|
|
76
|
-
"sha256": "
|
|
77
|
-
"size":
|
|
76
|
+
"sha256": "8400328308f7b3a3174c0306c5be13affd1da47f84a52f18f25d4c137707f2da",
|
|
77
|
+
"size": 677061646,
|
|
78
78
|
"backend": "yue2",
|
|
79
79
|
"role": "runtime",
|
|
80
80
|
"kind": "runtime",
|
|
@@ -14,6 +14,13 @@ from pathlib import Path
|
|
|
14
14
|
import numpy as np
|
|
15
15
|
import soundfile as sf
|
|
16
16
|
|
|
17
|
+
CAST_HINT = (
|
|
18
|
+
"aioli voice profiles create podcast-{role} --language es && "
|
|
19
|
+
'aioli voice profiles add-sample podcast-{role} muestra.wav -t "transcripción" '
|
|
20
|
+
"(o --cast ROL=muestra.wav|@perfil)"
|
|
21
|
+
)
|
|
22
|
+
"""Shared actionable hint (episode.py delegates its no-sample error here)."""
|
|
23
|
+
|
|
17
24
|
|
|
18
25
|
def sine_reference(
|
|
19
26
|
path: Path, hz: float = 220.0, seconds: float = 3.0, sample_rate: int = 22_050
|
|
@@ -33,24 +40,42 @@ def ensure_role_profile(
|
|
|
33
40
|
|
|
34
41
|
`sample` forms:
|
|
35
42
|
- `@profilename` — reuse an existing voice profile (no new samples).
|
|
36
|
-
- path to a wav(/mp3) reference sample.
|
|
43
|
+
- path to a wav(/mp3) reference sample (piper: `.onnx` voice file).
|
|
44
|
+
|
|
45
|
+
Same role always maps to the same profile id (consistent voice);
|
|
46
|
+
different roles map to different profiles. Without a sample or an
|
|
47
|
+
existing profile the caller must fail loudly with an actionable hint
|
|
48
|
+
(never a silent sine fallback — sine lives only in the stub path of
|
|
49
|
+
`episode.build_episode`).
|
|
37
50
|
"""
|
|
38
51
|
from aioli_voice.core.profiles import ProfileStore
|
|
39
52
|
|
|
53
|
+
hint = CAST_HINT.format(role=role.lower())
|
|
40
54
|
with ProfileStore(settings) as store:
|
|
41
55
|
if sample is not None and str(sample).startswith("@"):
|
|
42
56
|
want = str(sample)[1:]
|
|
43
57
|
for entry in store.list_profiles():
|
|
44
58
|
if entry.name == want:
|
|
45
59
|
return entry.id
|
|
46
|
-
raise ValueError(f"perfil de voz {want!r} no existe (rol {role})")
|
|
60
|
+
raise ValueError(f"perfil de voz {want!r} no existe (rol {role}); {hint}")
|
|
61
|
+
if sample is None:
|
|
62
|
+
for entry in store.list_profiles():
|
|
63
|
+
if entry.name == f"podcast-{role.lower()}":
|
|
64
|
+
if store.list_samples(entry.id):
|
|
65
|
+
return entry.id
|
|
66
|
+
raise ValueError(f"rol {role}: sin muestra ni perfil de voz; {hint}")
|
|
67
|
+
sample_path = Path(str(sample))
|
|
68
|
+
if not sample_path.exists():
|
|
69
|
+
raise ValueError(f"rol {role}: muestra {sample} no encontrada; {hint}")
|
|
47
70
|
for entry in store.list_profiles():
|
|
48
71
|
if entry.name == f"podcast-{role.lower()}":
|
|
72
|
+
# A new sample is never ignored: add it to the existing
|
|
73
|
+
# profile (ProfileStore dedupes by sha256, so re-adding
|
|
74
|
+
# the same file is a no-op).
|
|
75
|
+
store.add_sample(entry.id, sample_path, transcript)
|
|
49
76
|
return entry.id
|
|
50
77
|
profile_id = store.add_profile(f"podcast-{role.lower()}", language="es")
|
|
51
|
-
|
|
52
|
-
raise ValueError(f"rol {role}: sin muestra y sin referencia sintética")
|
|
53
|
-
store.add_sample(profile_id, sample, transcript)
|
|
78
|
+
store.add_sample(profile_id, sample_path, transcript)
|
|
54
79
|
return profile_id
|
|
55
80
|
|
|
56
81
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Typer CLI for the aioli `podcast` plugin (episode orchestrator)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .episode import PAUSE_DEFAULT_S, build_episode, parse_role_gains
|
|
10
|
+
from .script import parse_script
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="podcast", help="Episode orchestrator: script → voices + bed → mp3.", no_args_is_help=True
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command(name="episode")
|
|
18
|
+
def episode(
|
|
19
|
+
script: Path = typer.Argument(..., help="Markdown script ([ROL] lines)."),
|
|
20
|
+
out_dir: Path = typer.Option(Path("episodio"), "--out-dir", "-o"),
|
|
21
|
+
cast: list[str] | None = typer.Option(None, "--cast", help="ROL=muestra.wav (repeatable)."),
|
|
22
|
+
gain: list[str] | None = typer.Option(
|
|
23
|
+
None, "--gain", help="ROL=dB balance por rol (repetible, clamp ±12 dB)."
|
|
24
|
+
),
|
|
25
|
+
voice_engine: str = typer.Option("stub", "--voice-engine", help="Voice engine (stub offline)."),
|
|
26
|
+
bed_backend: str = typer.Option("stub", "--bed", help="Music backend (stub offline)."),
|
|
27
|
+
bed_desc: str | None = typer.Option(
|
|
28
|
+
None, "--bed-desc", help="Bed description (overrides [MUSIC])."
|
|
29
|
+
),
|
|
30
|
+
bed_file: Path | None = typer.Option(
|
|
31
|
+
None, "--bed-file", help="Use this audio as bed (looped)."
|
|
32
|
+
),
|
|
33
|
+
bed_mode: str = typer.Option("edges", "--bed-mode", help="edges (intro/outro) | full | off."),
|
|
34
|
+
pause_default: float = typer.Option(
|
|
35
|
+
PAUSE_DEFAULT_S,
|
|
36
|
+
"--pause-default",
|
|
37
|
+
help="Gap inter-línea natural en segundos (0 lo desactiva).",
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Render a full episode: mp3 + timeline.json + shownotes.md."""
|
|
41
|
+
from aioli_voice.config.settings import VoiceSettings
|
|
42
|
+
|
|
43
|
+
parsed = parse_script(script)
|
|
44
|
+
try:
|
|
45
|
+
role_gains = parse_role_gains(gain)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
typer.echo(str(exc), err=True)
|
|
48
|
+
raise typer.Exit(2) from None
|
|
49
|
+
cast_samples: dict[str, Path] = {}
|
|
50
|
+
for item in cast or []:
|
|
51
|
+
role, _, wav = item.partition("=")
|
|
52
|
+
if not role or not wav:
|
|
53
|
+
typer.echo(f"--cast debe ser ROL=muestra.wav (recibido {item!r})", err=True)
|
|
54
|
+
raise typer.Exit(2)
|
|
55
|
+
cast_samples[role.upper()] = Path(wav)
|
|
56
|
+
|
|
57
|
+
known_roles = {r.upper() for r in parsed.roles}
|
|
58
|
+
for flag, parsed_map in (("--cast", cast_samples), ("--gain", role_gains)):
|
|
59
|
+
unknown = sorted(set(parsed_map) - known_roles)
|
|
60
|
+
if unknown:
|
|
61
|
+
typer.echo(
|
|
62
|
+
f"{flag}: rol(es) {', '.join(unknown)} no están en el guion "
|
|
63
|
+
f"(roles: {', '.join(sorted(known_roles)) or 'ninguno'})",
|
|
64
|
+
err=True,
|
|
65
|
+
)
|
|
66
|
+
raise typer.Exit(2)
|
|
67
|
+
|
|
68
|
+
out = out_dir if out_dir.is_absolute() else Path.cwd() / out_dir
|
|
69
|
+
# Shared voice store: --cast @perfil reuses your real profiles.
|
|
70
|
+
settings = VoiceSettings.load()
|
|
71
|
+
settings.ensure_dirs()
|
|
72
|
+
result = build_episode(
|
|
73
|
+
parsed,
|
|
74
|
+
out,
|
|
75
|
+
settings,
|
|
76
|
+
cast_samples,
|
|
77
|
+
voice_engine,
|
|
78
|
+
role_gains,
|
|
79
|
+
bed_backend,
|
|
80
|
+
bed_desc,
|
|
81
|
+
bed_file,
|
|
82
|
+
bed_mode,
|
|
83
|
+
pause_default=pause_default,
|
|
84
|
+
)
|
|
85
|
+
typer.echo(f"🎙️ {result.mp3} ({result.duration_s:.1f}s, {result.lines} líneas)")
|
|
86
|
+
typer.echo(f" {result.timeline}\n {result.shownotes}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command(name="feed")
|
|
90
|
+
def feed(
|
|
91
|
+
dir: Path = typer.Argument(..., help="Directorio con episodios (mp3+timeline)."),
|
|
92
|
+
base_url: str | None = typer.Option(
|
|
93
|
+
None, "--base-url", help="URL pública antepuesta a los enclosures."
|
|
94
|
+
),
|
|
95
|
+
title: str | None = typer.Option(None, "--title", help="Título del canal."),
|
|
96
|
+
link: str | None = typer.Option(None, "--link", help="Link del canal."),
|
|
97
|
+
description: str | None = typer.Option(None, "--description", help="Descripción del canal."),
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Genera feed.xml local escaneando episodios (mp3+timeline) del dir."""
|
|
100
|
+
from .feed import build_feed
|
|
101
|
+
|
|
102
|
+
target = dir if dir.is_absolute() else Path.cwd() / dir
|
|
103
|
+
feed_path = build_feed(target, base_url, title, link, description)
|
|
104
|
+
typer.echo(f"📡 {feed_path}")
|
|
105
|
+
if not base_url:
|
|
106
|
+
typer.echo(
|
|
107
|
+
"Hint: las apps externas necesitan una URL pública; "
|
|
108
|
+
"usa --base-url https://tu-dominio/episodios",
|
|
109
|
+
err=True,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main() -> None:
|
|
114
|
+
app()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
|
@@ -37,33 +37,108 @@ def _silence(path: Path, seconds: float, sample_rate: int = 48_000) -> Path:
|
|
|
37
37
|
return path
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
PAUSE_DEFAULT_S = 0.35
|
|
41
|
+
"""Default inter-line natural gap in seconds (AC4: within 0.25-0.6s)."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cut_murmur_tail(
|
|
45
|
+
path: Path,
|
|
46
|
+
sample_rate: int = 24_000,
|
|
47
|
+
silence_s: float = 0.35,
|
|
48
|
+
rms_threshold: float = 0.01,
|
|
49
|
+
start_ratio: float = 0.4,
|
|
50
|
+
window_s: float = 0.1,
|
|
51
|
+
) -> float:
|
|
52
|
+
"""Cut a trailing near-silence tail; never cut a mid-line pause.
|
|
42
53
|
|
|
43
54
|
qwen3 with a foreign reference keeps mumbling after the sentence.
|
|
44
|
-
|
|
55
|
+
The clip is rewritten in place ONLY when its tail — scanned backwards
|
|
56
|
+
from the end — is a contiguous near-silence run (RMS below
|
|
57
|
+
`rms_threshold`) of at least `silence_s` seconds that starts after
|
|
58
|
+
`start_ratio` of the line. A natural mid-line pause followed by more
|
|
59
|
+
voice (voice→pause→voice) is preserved intact. Returns the kept
|
|
60
|
+
duration. `sample_rate` is kept for backward compatibility and
|
|
61
|
+
ignored (the file's own rate is used)."""
|
|
45
62
|
import numpy as np
|
|
46
63
|
import soundfile as sf
|
|
47
64
|
|
|
65
|
+
_ = sample_rate
|
|
48
66
|
x, sr = sf.read(str(path))
|
|
49
67
|
mono = x.mean(axis=1) if x.ndim > 1 else x
|
|
50
|
-
win = int(sr *
|
|
51
|
-
|
|
52
|
-
quiet
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
seg
|
|
57
|
-
|
|
58
|
-
run += len(seg)
|
|
59
|
-
if run >= quiet:
|
|
60
|
-
cut_at = i + win - run
|
|
61
|
-
break
|
|
68
|
+
win = max(1, int(sr * window_s))
|
|
69
|
+
quiet = int(sr * silence_s)
|
|
70
|
+
# Trailing quiet run: windows scanned from the end, stop at first voice.
|
|
71
|
+
tail_quiet = 0
|
|
72
|
+
for end in range(len(mono), 0, -win):
|
|
73
|
+
seg = mono[max(0, end - win) : end]
|
|
74
|
+
if np.sqrt((seg**2).mean()) < rms_threshold:
|
|
75
|
+
tail_quiet += len(seg)
|
|
62
76
|
else:
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
break
|
|
78
|
+
cut_at = len(mono) - tail_quiet
|
|
79
|
+
if tail_quiet >= quiet and cut_at >= int(len(mono) * start_ratio):
|
|
65
80
|
sf.write(str(path), x[:cut_at], sr)
|
|
66
|
-
|
|
81
|
+
return cut_at / sr
|
|
82
|
+
return len(mono) / sr
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _next_dialogue_follows(blocks: list, index: int) -> bool:
|
|
86
|
+
"""True when the next substantive block (skipping Music beds) is Dialogue."""
|
|
87
|
+
for later in blocks[index + 1 :]:
|
|
88
|
+
if isinstance(later, Music):
|
|
89
|
+
continue
|
|
90
|
+
return isinstance(later, Dialogue)
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
ROLE_GAIN_LIMIT_DB = 12.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _clamp_gain_db(db: float) -> float:
|
|
98
|
+
return max(-ROLE_GAIN_LIMIT_DB, min(ROLE_GAIN_LIMIT_DB, db))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_role_gains(items: list[str] | None) -> dict[str, float]:
|
|
102
|
+
"""Parse `--gain ROL=dB` (repeatable) into `{ROL: dB}` clamped to ±12 dB."""
|
|
103
|
+
gains: dict[str, float] = {}
|
|
104
|
+
for item in items or []:
|
|
105
|
+
role, sep, raw = item.partition("=")
|
|
106
|
+
if not role or not sep:
|
|
107
|
+
raise ValueError(f"--gain debe ser ROL=dB (recibido {item!r})")
|
|
108
|
+
try:
|
|
109
|
+
db = float(raw)
|
|
110
|
+
except ValueError:
|
|
111
|
+
raise ValueError(f"--gain debe ser ROL=dB (recibido {item!r})") from None
|
|
112
|
+
gains[role.strip().upper()] = _clamp_gain_db(db)
|
|
113
|
+
return gains
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def apply_gain_db(inp: Path, out: Path, db: float) -> Path:
|
|
117
|
+
"""Apply `db` gain (clamped ±12 dB) with a peak limiter (< 0 dBFS)."""
|
|
118
|
+
import numpy as np
|
|
119
|
+
import soundfile as sf
|
|
120
|
+
|
|
121
|
+
db = _clamp_gain_db(db)
|
|
122
|
+
x, sr = sf.read(str(inp), dtype="float32")
|
|
123
|
+
y = np.asarray(x, dtype=np.float32) * float(10.0 ** (db / 20.0))
|
|
124
|
+
peak = float(np.abs(y).max()) if y.size else 0.0
|
|
125
|
+
if peak >= 1.0:
|
|
126
|
+
y = (y * (0.99 / peak)).astype(np.float32)
|
|
127
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
sf.write(str(out), y, sr)
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _peak_limit_wav(path: Path, ceiling: float = 0.99) -> None:
|
|
133
|
+
"""In-place peak normalize so the file never clips (peak < 0 dBFS)."""
|
|
134
|
+
import numpy as np
|
|
135
|
+
import soundfile as sf
|
|
136
|
+
|
|
137
|
+
x, sr = sf.read(str(path), dtype="float32")
|
|
138
|
+
arr = np.asarray(x, dtype=np.float32)
|
|
139
|
+
peak = float(np.abs(arr).max()) if arr.size else 0.0
|
|
140
|
+
if peak >= 1.0:
|
|
141
|
+
sf.write(str(path), (arr * (ceiling / peak)).astype(np.float32), sr)
|
|
67
142
|
|
|
68
143
|
|
|
69
144
|
def build_episode(
|
|
@@ -72,10 +147,12 @@ def build_episode(
|
|
|
72
147
|
voice_settings,
|
|
73
148
|
cast_samples: dict[str, Path] | None = None,
|
|
74
149
|
voice_engine: str = "stub",
|
|
150
|
+
role_gains: dict[str, float] | None = None,
|
|
75
151
|
bed_backend: str = "stub",
|
|
76
152
|
bed_desc: str | None = None,
|
|
77
153
|
bed_file: Path | None = None,
|
|
78
154
|
bed_mode: str = "edges",
|
|
155
|
+
pause_default: float = PAUSE_DEFAULT_S,
|
|
79
156
|
) -> EpisodeResult:
|
|
80
157
|
"""Run the full pipeline. Returns paths + stats."""
|
|
81
158
|
from aioli_master import ops
|
|
@@ -87,11 +164,19 @@ def build_episode(
|
|
|
87
164
|
work.mkdir(exist_ok=True)
|
|
88
165
|
|
|
89
166
|
service = GenerationService(voice_settings)
|
|
167
|
+
gains = {str(r).upper(): _clamp_gain_db(float(v)) for r, v in (role_gains or {}).items()}
|
|
90
168
|
profile_ids: dict[str, int] = {}
|
|
91
169
|
for i, role in enumerate(script.roles):
|
|
92
170
|
sample = cast_samples.get(role)
|
|
93
171
|
if sample is None:
|
|
94
|
-
|
|
172
|
+
if voice_engine == "stub":
|
|
173
|
+
sample = cast.sine_reference(work / f"ref-{role.lower()}.wav", hz=180.0 + 40.0 * i)
|
|
174
|
+
else:
|
|
175
|
+
# Real engine without --cast: resolve the existing
|
|
176
|
+
# `podcast-<role>` profile or fail loudly with the shared
|
|
177
|
+
# actionable hint (delegated, never a silent sine fallback).
|
|
178
|
+
profile_ids[role] = cast.ensure_role_profile(voice_settings, role, None)
|
|
179
|
+
continue
|
|
95
180
|
profile_ids[role] = cast.ensure_role_profile(voice_settings, role, sample)
|
|
96
181
|
|
|
97
182
|
# 1-2. Speak every line.
|
|
@@ -124,6 +209,18 @@ def build_episode(
|
|
|
124
209
|
print(f" [trim] {raw.name} → {kept:.1f}s")
|
|
125
210
|
prep = work / f"line-{n:03d}-48.wav"
|
|
126
211
|
ops.run(ops.convert_argv(raw, prep))
|
|
212
|
+
db = gains.get(block.role.upper(), 0.0)
|
|
213
|
+
if db:
|
|
214
|
+
gained = work / f"line-{n:03d}-48-g.wav"
|
|
215
|
+
apply_gain_db(prep, gained, db)
|
|
216
|
+
prep = gained
|
|
217
|
+
if pause_default > 0 and _next_dialogue_follows(script.blocks, n):
|
|
218
|
+
pad_raw = _silence(work / f"pad-{n}.wav", pause_default)
|
|
219
|
+
pad48 = work / f"pad-{n}-48.wav"
|
|
220
|
+
ops.run(ops.convert_argv(pad_raw, pad48))
|
|
221
|
+
padded = work / f"line-{n:03d}-48-p.wav"
|
|
222
|
+
ops.run(ops.concat_argv([prep, pad48], padded))
|
|
223
|
+
prep = padded
|
|
127
224
|
dur = ops.info(prep)["duration_s"]
|
|
128
225
|
timeline.append(
|
|
129
226
|
{
|
|
@@ -139,6 +236,7 @@ def build_episode(
|
|
|
139
236
|
|
|
140
237
|
voices = work / "voices.wav"
|
|
141
238
|
ops.run(ops.concat_argv(parts, voices))
|
|
239
|
+
_peak_limit_wav(voices)
|
|
142
240
|
|
|
143
241
|
# 3-4. Bed + duck + loudnorm → mp3.
|
|
144
242
|
mixed = voices
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Local RSS feed for podcast episodes (iTunes-compatible, offline).
|
|
2
|
+
|
|
3
|
+
Reads episode dirs produced by `build_episode` (mp3 + timeline.json +
|
|
4
|
+
shownotes.md) and writes `feed.xml`. Without `--base-url` the enclosures
|
|
5
|
+
are absolute `file://` URLs (only local players can fetch them); with a
|
|
6
|
+
base URL it is prepended so external apps can subscribe.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from email.utils import formatdate
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
ITUNES_NS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
|
|
19
|
+
ATOM_NS = "http://www.w3.org/2005/Atom"
|
|
20
|
+
|
|
21
|
+
ET.register_namespace("itunes", ITUNES_NS)
|
|
22
|
+
ET.register_namespace("atom", ATOM_NS)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FeedEpisode:
|
|
27
|
+
title: str
|
|
28
|
+
mp3: Path
|
|
29
|
+
url: str
|
|
30
|
+
length: int
|
|
31
|
+
pub_date: str
|
|
32
|
+
duration: str
|
|
33
|
+
description: str
|
|
34
|
+
guid: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_duration(seconds: float) -> str:
|
|
38
|
+
"""Format seconds as HH:MM:SS for `itunes:duration`."""
|
|
39
|
+
try:
|
|
40
|
+
total = max(0, int(round(float(seconds))))
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
total = 0
|
|
43
|
+
h, rem = divmod(total, 3600)
|
|
44
|
+
m, s = divmod(rem, 60)
|
|
45
|
+
return f"{h:02d}:{m:02d}:{s:02d}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _rfc822(mtime: float) -> str:
|
|
49
|
+
return formatdate(mtime, usegmt=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _episode_meta(
|
|
53
|
+
mp3: Path, sibling_dir: Path, base_url: str | None, url_path: str | None = None
|
|
54
|
+
) -> FeedEpisode:
|
|
55
|
+
timeline_path = sibling_dir / "timeline.json"
|
|
56
|
+
if not timeline_path.exists():
|
|
57
|
+
alt = mp3.with_suffix(".json")
|
|
58
|
+
timeline_path = alt if alt.exists() else timeline_path
|
|
59
|
+
notes_path = sibling_dir / "shownotes.md"
|
|
60
|
+
if not notes_path.exists():
|
|
61
|
+
alt = mp3.with_suffix(".md")
|
|
62
|
+
notes_path = alt if alt.exists() else notes_path
|
|
63
|
+
|
|
64
|
+
title = mp3.stem
|
|
65
|
+
duration_s = 0.0
|
|
66
|
+
if timeline_path.exists():
|
|
67
|
+
try:
|
|
68
|
+
timeline = json.loads(timeline_path.read_text(encoding="utf-8"))
|
|
69
|
+
title = str(timeline.get("title") or title)
|
|
70
|
+
duration_s = float(timeline.get("duration_s") or 0.0)
|
|
71
|
+
except (ValueError, TypeError, AttributeError):
|
|
72
|
+
pass
|
|
73
|
+
description = ""
|
|
74
|
+
if notes_path.exists():
|
|
75
|
+
try:
|
|
76
|
+
description = notes_path.read_text(encoding="utf-8").strip()
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
if base_url:
|
|
81
|
+
url = base_url.rstrip("/") + "/" + (url_path or mp3.name)
|
|
82
|
+
else:
|
|
83
|
+
url = mp3.resolve().as_uri()
|
|
84
|
+
stat = mp3.stat()
|
|
85
|
+
return FeedEpisode(
|
|
86
|
+
title=title,
|
|
87
|
+
mp3=mp3,
|
|
88
|
+
url=url,
|
|
89
|
+
length=stat.st_size,
|
|
90
|
+
pub_date=_rfc822(stat.st_mtime),
|
|
91
|
+
duration=format_duration(duration_s),
|
|
92
|
+
description=description,
|
|
93
|
+
guid=url,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def collect_episodes(out_dir: Path, base_url: str | None = None) -> list[FeedEpisode]:
|
|
98
|
+
"""Scan `out_dir` for episodes (mp3 + timeline) — itself or subdirs."""
|
|
99
|
+
out_dir = out_dir.resolve()
|
|
100
|
+
mp3s: list[Path] = sorted(out_dir.glob("*.mp3"))
|
|
101
|
+
for sub in sorted(p for p in out_dir.iterdir() if p.is_dir()):
|
|
102
|
+
mp3s.extend(sorted(sub.glob("*.mp3")))
|
|
103
|
+
return [
|
|
104
|
+
_episode_meta(mp3, mp3.parent, base_url, mp3.relative_to(out_dir).as_posix())
|
|
105
|
+
for mp3 in mp3s
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_feed(
|
|
110
|
+
out_dir: Path | list[Path],
|
|
111
|
+
base_url: str | None = None,
|
|
112
|
+
title: str | None = None,
|
|
113
|
+
link: str | None = None,
|
|
114
|
+
description: str | None = None,
|
|
115
|
+
) -> Path:
|
|
116
|
+
"""Build `feed.xml` for episodes under `out_dir` (or an episodes list).
|
|
117
|
+
|
|
118
|
+
Returns the feed.xml path. Channel tags: title, link, description,
|
|
119
|
+
language es-ES, itunes:author, itunes:explicit false, itunes:category,
|
|
120
|
+
atom:link self. Items carry title, HTML-escaped shownotes description,
|
|
121
|
+
pubDate RFC822, enclosure url/length/type audio/mpeg, guid
|
|
122
|
+
isPermaLink=false and itunes:duration HH:MM:SS.
|
|
123
|
+
"""
|
|
124
|
+
if isinstance(out_dir, list):
|
|
125
|
+
items = [Path(m).resolve() for m in out_dir]
|
|
126
|
+
mp3s: list[Path] = []
|
|
127
|
+
for entry in items:
|
|
128
|
+
if entry.is_dir():
|
|
129
|
+
mp3s.extend(sorted(entry.glob("*.mp3")))
|
|
130
|
+
elif entry.is_file():
|
|
131
|
+
mp3s.append(entry)
|
|
132
|
+
try:
|
|
133
|
+
common = Path(os.path.commonpath([str(i) for i in items]))
|
|
134
|
+
except ValueError:
|
|
135
|
+
common = None
|
|
136
|
+
episodes = []
|
|
137
|
+
for mp3 in sorted(mp3s):
|
|
138
|
+
try:
|
|
139
|
+
rel = mp3.relative_to(common).as_posix() if common else None
|
|
140
|
+
except ValueError:
|
|
141
|
+
rel = None
|
|
142
|
+
episodes.append(_episode_meta(mp3, mp3.parent, base_url, rel))
|
|
143
|
+
if out_dir:
|
|
144
|
+
target = Path(str(out_dir[0])).resolve()
|
|
145
|
+
target = (target.parent if target.is_file() else target) / "feed.xml"
|
|
146
|
+
else:
|
|
147
|
+
target = Path("feed.xml")
|
|
148
|
+
feed_dir = target.parent
|
|
149
|
+
else:
|
|
150
|
+
feed_dir = Path(out_dir).resolve()
|
|
151
|
+
episodes = collect_episodes(feed_dir, base_url)
|
|
152
|
+
target = feed_dir / "feed.xml"
|
|
153
|
+
|
|
154
|
+
channel_title = title or (episodes[0].title if len(episodes) == 1 else "Aioli Podcast")
|
|
155
|
+
channel_link = link or (base_url.rstrip("/") if base_url else feed_dir.as_uri())
|
|
156
|
+
channel_desc = description or "Feed local generado por aioli podcast."
|
|
157
|
+
self_href = base_url.rstrip("/") + "/feed.xml" if base_url else target.resolve().as_uri()
|
|
158
|
+
|
|
159
|
+
rss = ET.Element("rss", {"version": "2.0"})
|
|
160
|
+
channel = ET.SubElement(rss, "channel")
|
|
161
|
+
ET.SubElement(channel, "title").text = channel_title
|
|
162
|
+
ET.SubElement(channel, "link").text = channel_link
|
|
163
|
+
ET.SubElement(channel, "description").text = channel_desc
|
|
164
|
+
ET.SubElement(channel, "language").text = "es-ES"
|
|
165
|
+
ET.SubElement(channel, f"{{{ITUNES_NS}}}author").text = channel_title
|
|
166
|
+
ET.SubElement(channel, f"{{{ITUNES_NS}}}explicit").text = "false"
|
|
167
|
+
ET.SubElement(channel, f"{{{ITUNES_NS}}}category", {"text": "Society & Culture"})
|
|
168
|
+
ET.SubElement(
|
|
169
|
+
channel,
|
|
170
|
+
f"{{{ATOM_NS}}}link",
|
|
171
|
+
{"href": self_href, "rel": "self", "type": "application/rss+xml"},
|
|
172
|
+
)
|
|
173
|
+
for ep in episodes:
|
|
174
|
+
item_el = ET.SubElement(channel, "item")
|
|
175
|
+
ET.SubElement(item_el, "title").text = ep.title
|
|
176
|
+
ET.SubElement(item_el, "description").text = ep.description
|
|
177
|
+
ET.SubElement(item_el, "pubDate").text = ep.pub_date
|
|
178
|
+
ET.SubElement(
|
|
179
|
+
item_el,
|
|
180
|
+
"enclosure",
|
|
181
|
+
{"url": ep.url, "length": str(ep.length), "type": "audio/mpeg"},
|
|
182
|
+
)
|
|
183
|
+
ET.SubElement(item_el, "guid", {"isPermaLink": "false"}).text = ep.guid
|
|
184
|
+
ET.SubElement(item_el, f"{{{ITUNES_NS}}}duration").text = ep.duration
|
|
185
|
+
|
|
186
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
tree = ET.ElementTree(rss)
|
|
188
|
+
tree.write(str(target), encoding="utf-8", xml_declaration=True)
|
|
189
|
+
return target
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
"""Typer CLI for the aioli `podcast` plugin (episode orchestrator)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import typer
|
|
8
|
-
|
|
9
|
-
from .episode import build_episode
|
|
10
|
-
from .script import parse_script
|
|
11
|
-
|
|
12
|
-
app = typer.Typer(
|
|
13
|
-
name="podcast", help="Episode orchestrator: script → voices + bed → mp3.", no_args_is_help=True
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@app.command(name="episode")
|
|
18
|
-
def episode(
|
|
19
|
-
script: Path = typer.Argument(..., help="Markdown script ([ROL] lines)."),
|
|
20
|
-
out_dir: Path = typer.Option(Path("episodio"), "--out-dir", "-o"),
|
|
21
|
-
cast: list[str] | None = typer.Option(None, "--cast", help="ROL=muestra.wav (repeatable)."),
|
|
22
|
-
voice_engine: str = typer.Option("stub", "--voice-engine", help="Voice engine (stub offline)."),
|
|
23
|
-
bed_backend: str = typer.Option("stub", "--bed", help="Music backend (stub offline)."),
|
|
24
|
-
bed_desc: str | None = typer.Option(
|
|
25
|
-
None, "--bed-desc", help="Bed description (overrides [MUSIC])."
|
|
26
|
-
),
|
|
27
|
-
bed_file: Path | None = typer.Option(
|
|
28
|
-
None, "--bed-file", help="Use this audio as bed (looped)."
|
|
29
|
-
),
|
|
30
|
-
bed_mode: str = typer.Option("edges", "--bed-mode", help="edges (intro/outro) | full | off."),
|
|
31
|
-
) -> None:
|
|
32
|
-
"""Render a full episode: mp3 + timeline.json + shownotes.md."""
|
|
33
|
-
from aioli_voice.config.settings import VoiceSettings
|
|
34
|
-
|
|
35
|
-
parsed = parse_script(script)
|
|
36
|
-
cast_samples: dict[str, Path] = {}
|
|
37
|
-
for item in cast or []:
|
|
38
|
-
role, _, wav = item.partition("=")
|
|
39
|
-
if not role or not wav:
|
|
40
|
-
typer.echo(f"--cast debe ser ROL=muestra.wav (recibido {item!r})", err=True)
|
|
41
|
-
raise typer.Exit(2)
|
|
42
|
-
cast_samples[role.upper()] = Path(wav)
|
|
43
|
-
|
|
44
|
-
out = out_dir if out_dir.is_absolute() else Path.cwd() / out_dir
|
|
45
|
-
# Shared voice store: --cast @perfil reuses your real profiles.
|
|
46
|
-
settings = VoiceSettings.load()
|
|
47
|
-
settings.ensure_dirs()
|
|
48
|
-
result = build_episode(
|
|
49
|
-
parsed, out, settings, cast_samples, voice_engine, bed_backend, bed_desc, bed_file, bed_mode
|
|
50
|
-
)
|
|
51
|
-
typer.echo(f"🎙️ {result.mp3} ({result.duration_s:.1f}s, {result.lines} líneas)")
|
|
52
|
-
typer.echo(f" {result.timeline}\n {result.shownotes}")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def main() -> None:
|
|
56
|
-
app()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if __name__ == "__main__":
|
|
60
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|