|
23 | 23 | from aai_cli.core import choices, client, config_builder, signals, stdio, youtube |
24 | 24 | from aai_cli.core.errors import UsageError, mutually_exclusive |
25 | 25 | from aai_cli.core.microphone import MicrophoneSource |
26 | | -from aai_cli.streaming import naming, record, transcript, turn_presets |
| 26 | +from aai_cli.streaming import naming, record, savedir, transcript, turn_presets |
| 27 | +from aai_cli.streaming.batch import stream_batch_sources |
27 | 28 | from aai_cli.streaming.macos import MacSystemAudioSource |
28 | 29 | from aai_cli.streaming.render import StreamRenderer |
29 | | -from aai_cli.streaming.session import ( |
30 | | - SourceOptions, |
31 | | - StreamSession, |
32 | | - resolve_output_modes, |
33 | | - stream_batch_sources, |
34 | | - validate_sources, |
35 | | -) |
| 30 | +from aai_cli.streaming.session import StreamSession |
36 | 31 | from aai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource |
37 | 32 | from aai_cli.streaming.turn_presets import TurnDetectionPreset |
| 33 | +from aai_cli.streaming.validate import SourceOptions, resolve_output_modes, validate_sources |
38 | 34 | from aai_cli.ui import output |
39 | 35 | from aai_cli.ui.follow import FollowRenderer |
40 | 36 |
|
@@ -90,6 +86,8 @@ class StreamOptions: |
90 | 86 | save_transcript: Path | None |
91 | 87 | save_dir: Path | None |
92 | 88 | name: str | None |
| 89 | + auto_name: bool |
| 90 | + no_save_audio: bool |
93 | 91 |
|
94 | 92 | def source_options(self) -> SourceOptions: |
95 | 93 | """The audio-input subset, in the shape the validation/dispatch helpers read.""" |
@@ -205,57 +203,97 @@ class SaveTargets: |
205 | 203 | ``audio`` tees a single source to one WAV; ``audio_by_label`` instead maps each |
206 | 204 | parallel ``--system-audio`` channel ("you", "system") to its own WAV when the two |
207 | 205 | streams can't share a file. At most one of the two is set; ``transcript`` is the |
208 | | - single shared transcript either way. |
| 206 | + single shared transcript either way. ``plan`` is set only under ``--save-dir`` and |
| 207 | + carries the post-stream finalization (auto-name rename, ``--llm`` note, sidecar). |
209 | 208 | """ |
210 | 209 |
|
211 | 210 | transcript: Path | None = None |
212 | 211 | audio: Path | None = None |
213 | 212 | audio_by_label: dict[str, Path] | None = None |
| 213 | + plan: savedir.SaveDirPlan | None = None |
| 214 | + |
| 215 | + |
| 216 | +def _save_dir_targets(opts: StreamOptions, sources: SourceOptions, save_dir: Path) -> SaveTargets: |
| 217 | + """Resolve ``--save-dir`` into auto-named targets plus the finalization plan. |
| 218 | +
|
| 219 | + ``--save-dir`` owns filename assembly, so it rejects the explicit |
| 220 | + ``--save-audio``/``--save-transcript`` paths and the conflicting ``--name``/ |
| 221 | + ``--auto-name`` title pair. Two parallel ``--system-audio`` streams can't tee to one |
| 222 | + WAV, so each channel gets its own ``<stem>-{you,system}.wav`` (one shared transcript); |
| 223 | + ``--no-save-audio`` drops the WAV(s) entirely. |
| 224 | + """ |
| 225 | + mutually_exclusive( |
| 226 | + ("--save-dir", True), |
| 227 | + ("--save-audio", opts.save_audio is not None), |
| 228 | + ("--save-transcript", opts.save_transcript is not None), |
| 229 | + suggestion="--save-dir names the files for you; drop the explicit path.", |
| 230 | + ) |
| 231 | + mutually_exclusive( |
| 232 | + ("--name", opts.name is not None), |
| 233 | + ("--auto-name", opts.auto_name), |
| 234 | + suggestion="Both set the title — pass --name for an explicit one or " |
| 235 | + "--auto-name to derive it from the transcript.", |
| 236 | + ) |
| 237 | + # Local wall-clock time (what a meeting filename wants); the explicit utc-then- |
| 238 | + # astimezone keeps the now() call timezone-aware for the linter. |
| 239 | + now = datetime.now(UTC).astimezone() |
| 240 | + plan = savedir.SaveDirPlan( |
| 241 | + save_dir=save_dir, |
| 242 | + now=now, |
| 243 | + name=opts.name, |
| 244 | + auto_name=opts.auto_name, |
| 245 | + write_note=bool(opts.llm_prompt), |
| 246 | + ) |
| 247 | + paths = plan.paths |
| 248 | + naming.ensure_dir(paths.directory) |
| 249 | + if opts.no_save_audio: |
| 250 | + # Transcript + sidecar (+ note) only; no WAV teed for any source. |
| 251 | + return SaveTargets(transcript=paths.transcript, plan=plan) |
| 252 | + if sources.system_audio: |
| 253 | + # Parallel mic + system: one WAV per channel beside the shared transcript. |
| 254 | + return SaveTargets( |
| 255 | + transcript=paths.transcript, |
| 256 | + audio_by_label={ |
| 257 | + "you": naming.channel_audio(paths.audio, "you"), |
| 258 | + "system": naming.channel_audio(paths.audio, "system"), |
| 259 | + }, |
| 260 | + plan=plan, |
| 261 | + ) |
| 262 | + if sources.system_audio_only: |
| 263 | + # A lone system-audio stream; label its single WAV so it reads like the pair. |
| 264 | + return SaveTargets( |
| 265 | + transcript=paths.transcript, |
| 266 | + audio=naming.channel_audio(paths.audio, "system"), |
| 267 | + plan=plan, |
| 268 | + ) |
| 269 | + return SaveTargets(transcript=paths.transcript, audio=paths.audio, plan=plan) |
214 | 270 |
|
215 | 271 |
|
216 | 272 | def _resolve_save_targets(opts: StreamOptions, sources: SourceOptions) -> SaveTargets: |
217 | 273 | """Resolve the save flags into the destinations the session writes. |
218 | 274 |
|
219 | | - ``--save-dir`` owns filename assembly — it auto-names the transcript and a matching |
220 | | - WAV under ``DIR/YYYY-MM-DD/`` — so it can't be combined with the explicit |
221 | | - ``--save-audio``/``--save-transcript`` paths, and ``--name`` only feeds that assembly. |
222 | | - Two parallel ``--system-audio`` streams can't tee to one WAV, so under ``--save-dir`` |
223 | | - each channel gets its own ``<stem>-{you,system}.wav`` (one shared transcript), and the |
224 | | - explicit single-path ``--save-audio`` is rejected outright. |
| 275 | + ``--save-dir`` owns filename assembly (see ``_save_dir_targets``); the explicit |
| 276 | + ``--save-audio``/``--save-transcript`` paths are the fallback, with the save-dir-only |
| 277 | + ``--name``/``--auto-name``/``--no-save-audio`` flags rejected outside it. |
225 | 278 | """ |
226 | 279 | if opts.save_dir is not None: |
227 | | - mutually_exclusive( |
228 | | - ("--save-dir", True), |
229 | | - ("--save-audio", opts.save_audio is not None), |
230 | | - ("--save-transcript", opts.save_transcript is not None), |
231 | | - suggestion="--save-dir names the files for you; drop the explicit path.", |
232 | | - ) |
233 | | - # Local wall-clock time (what a meeting filename wants); the explicit utc-then- |
234 | | - # astimezone keeps the now() call timezone-aware for the linter. |
235 | | - now = datetime.now(UTC).astimezone() |
236 | | - paths = naming.resolve(opts.save_dir, opts.name, now=now) |
237 | | - naming.ensure_dir(paths.transcript.parent) |
238 | | - if sources.system_audio: |
239 | | - # Parallel mic + system: one WAV per channel beside the shared transcript. |
240 | | - return SaveTargets( |
241 | | - transcript=paths.transcript, |
242 | | - audio_by_label={ |
243 | | - "you": naming.channel_audio(paths.audio, "you"), |
244 | | - "system": naming.channel_audio(paths.audio, "system"), |
245 | | - }, |
246 | | - ) |
247 | | - if sources.system_audio_only: |
248 | | - # A lone system-audio stream; label its single WAV so it reads like the pair. |
249 | | - return SaveTargets( |
250 | | - transcript=paths.transcript, audio=naming.channel_audio(paths.audio, "system") |
251 | | - ) |
252 | | - return SaveTargets(transcript=paths.transcript, audio=paths.audio) |
| 280 | + return _save_dir_targets(opts, sources, opts.save_dir) |
253 | 281 | if opts.name is not None: |
254 | 282 | raise UsageError( |
255 | 283 | "--name applies only with --save-dir.", |
256 | 284 | suggestion="Pass --save-dir DIR to auto-name the files, " |
257 | 285 | "or --save-transcript PATH for an explicit path.", |
258 | 286 | ) |
| 287 | + if opts.auto_name: |
| 288 | + raise UsageError( |
| 289 | + "--auto-name applies only with --save-dir.", |
| 290 | + suggestion="Pass --save-dir DIR so there's an auto-named file to title.", |
| 291 | + ) |
| 292 | + if opts.no_save_audio: |
| 293 | + raise UsageError( |
| 294 | + "--no-save-audio applies only with --save-dir.", |
| 295 | + suggestion="Omit --save-audio to skip the WAV, or pass --save-dir DIR.", |
| 296 | + ) |
259 | 297 | if opts.save_audio is not None: |
260 | 298 | if sources.system_audio: |
261 | 299 | raise UsageError( |
@@ -343,6 +381,8 @@ def _collect_batch_sources(opts: StreamOptions, *, text_mode: bool) -> list[str] |
343 | 381 | ("--save-transcript", opts.save_transcript is not None), |
344 | 382 | ("--save-dir", opts.save_dir is not None), |
345 | 383 | ("--name", opts.name is not None), |
| 384 | + ("--auto-name", opts.auto_name), |
| 385 | + ("--no-save-audio", opts.no_save_audio), |
346 | 386 | suggestion="--from-stdin streams many sources; saving applies to a single run.", |
347 | 387 | ) |
348 | 388 | mutually_exclusive( |
@@ -434,6 +474,7 @@ def run_stream(opts: StreamOptions, state: AppState, *, json_mode: bool) -> None |
434 | 474 | save_audio=targets.audio, |
435 | 475 | save_audio_by_label=targets.audio_by_label, |
436 | 476 | save_transcript=targets.transcript, |
| 477 | + save_plan=targets.plan, |
437 | 478 | llm_interval=opts.llm_interval, |
438 | 479 | ) |
439 | 480 | with signals.terminate_as_interrupt(): |
|
0 commit comments