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.
Files changed (116) hide show
  1. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/PKG-INFO +1 -1
  2. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/pyproject.toml +6 -1
  3. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/models_data.py +2 -2
  4. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/cast.py +30 -5
  5. aioli_cli-0.1.37/src/aioli_podcast/cli.py +118 -0
  6. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/episode.py +117 -19
  7. aioli_cli-0.1.37/src/aioli_podcast/feed.py +189 -0
  8. aioli_cli-0.1.35/src/aioli_podcast/cli.py +0 -60
  9. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/.gitignore +0 -0
  10. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/LICENSE +0 -0
  11. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/README.md +0 -0
  12. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/_plugins.py +0 -0
  13. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/fetch_ui.py +0 -0
  14. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/main.py +0 -0
  15. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/readiness.py +0 -0
  16. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/registry.py +0 -0
  17. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/setup.py +0 -0
  18. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_cli/status.py +0 -0
  19. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/__init__.py +0 -0
  20. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/advise.py +0 -0
  21. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/config.py +0 -0
  22. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/doctor.py +0 -0
  23. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/fetch.py +0 -0
  24. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/probe.py +0 -0
  25. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_contract/readiness.py +0 -0
  26. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/__init__.py +0 -0
  27. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/cli.py +0 -0
  28. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/companions.py +0 -0
  29. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/config.py +0 -0
  30. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/data/models.toml +0 -0
  31. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/doctor.py +0 -0
  32. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/gpu.py +0 -0
  33. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/launcher.py +0 -0
  34. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/models.py +0 -0
  35. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/models_data.py +0 -0
  36. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/plugin.py +0 -0
  37. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/presets.py +0 -0
  38. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/stub.py +0 -0
  39. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_image/ui.py +0 -0
  40. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_master/cli.py +0 -0
  41. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_master/doctor.py +0 -0
  42. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_master/ops.py +0 -0
  43. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_master/plugin.py +0 -0
  44. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/__init__.py +0 -0
  45. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/__init__.py +0 -0
  46. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/acestep.py +0 -0
  47. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/base.py +0 -0
  48. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/llm_caption.py +0 -0
  49. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/minimax_comfy.py +0 -0
  50. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/process.py +0 -0
  51. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/stub.py +0 -0
  52. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/backends/yue2.py +0 -0
  53. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/__init__.py +0 -0
  54. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/backend_cmd.py +0 -0
  55. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/generate_cmd.py +0 -0
  56. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/inspire_cmd.py +0 -0
  57. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/cli_commands/models_cmd.py +0 -0
  58. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/client.py +0 -0
  59. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/config.py +0 -0
  60. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/config_data/default.toml +0 -0
  61. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/__init__.py +0 -0
  62. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/formats.py +0 -0
  63. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/generate.py +0 -0
  64. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/jobs.py +0 -0
  65. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/core/layout.py +0 -0
  66. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/doctor.py +0 -0
  67. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/generator.py +0 -0
  68. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/history.py +0 -0
  69. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/jsonstore.py +0 -0
  70. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/models.py +0 -0
  71. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/paths.py +0 -0
  72. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/plugin.py +0 -0
  73. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/presets.py +0 -0
  74. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/prompts.py +0 -0
  75. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/server.py +0 -0
  76. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/transport/__init__.py +0 -0
  77. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/transport/client.py +0 -0
  78. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_music/tui.py +0 -0
  79. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/doctor.py +0 -0
  80. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/plugin.py +0 -0
  81. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_podcast/script.py +0 -0
  82. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/cli.py +0 -0
  83. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/doctor.py +0 -0
  84. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/models_data.py +0 -0
  85. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/paths.py +0 -0
  86. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/plugin.py +0 -0
  87. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/stub.py +0 -0
  88. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/wgp.py +0 -0
  89. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_video/worker.py +0 -0
  90. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/__init__.py +0 -0
  91. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/doctor.py +0 -0
  92. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/engines_cmd.py +0 -0
  93. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/generate.py +0 -0
  94. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/models_cmd.py +0 -0
  95. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/cli/profiles.py +0 -0
  96. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/config/__init__.py +0 -0
  97. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/config/settings.py +0 -0
  98. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/__init__.py +0 -0
  99. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/generation.py +0 -0
  100. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/model_manager.py +0 -0
  101. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/profiles.py +0 -0
  102. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/registry.py +0 -0
  103. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/core/types.py +0 -0
  104. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/devices.py +0 -0
  105. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/doctor.py +0 -0
  106. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/__init__.py +0 -0
  107. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/chatterbox.py +0 -0
  108. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/kokoro.py +0 -0
  109. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/moss.py +0 -0
  110. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/piper.py +0 -0
  111. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/qwen3.py +0 -0
  112. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/stub.py +0 -0
  113. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/voxcpm.py +0 -0
  114. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/engines/xtts.py +0 -0
  115. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/models_data.py +0 -0
  116. {aioli_cli-0.1.35 → aioli_cli-0.1.37}/src/aioli_voice/plugin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aioli-cli
3
- Version: 0.1.35
3
+ Version: 0.1.37
4
4
  Summary: aioli — local-first AI media suite CLI (images, video, music, voice, podcast)
5
5
  Author: aioli contributors
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "aioli-cli"
3
- version = "0.1.35"
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": "296ce2c94a4c429dc7ba84e6f5374662d6515512868c7b78dd4a095eaf08453f",
77
- "size": 14403139,
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
- if sample is None:
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
- def _cut_murmur_tail(path: Path, sample_rate: int = 24_000) -> float:
41
- """Cut cross-lingual babble tail: first long near-silence after 40%.
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
- Returns the kept duration (file rewritten in place if cut)."""
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 * 0.1)
51
- start = int(len(mono) * 0.4)
52
- quiet = int(sr * 0.35)
53
- run = 0
54
- cut_at = len(mono)
55
- for i in range(start, len(mono), win):
56
- seg = mono[i : i + win]
57
- if np.sqrt((seg**2).mean()) < 0.01:
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
- run = 0
64
- if cut_at < len(mono):
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
- return cut_at / sr
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
- sample = cast.sine_reference(work / f"ref-{role.lower()}.wav", hz=180.0 + 40.0 * i)
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