#!/usr/bin/env python3
"""velcro-transcribe: an audio file → text, the way Claude Code's /voice or Codex's voice input does it.

Two engines, tried in the order given, each only if you're signed in to it:
  claude  Claude Code's speech-to-text stream (voice_stream), with your own Claude Code login.
  codex   ChatGPT's transcribe endpoint, with the ChatGPT login Codex keeps in ~/.codex/auth.json.
`velcro transcribe` runs it; it also runs on its own:

  velcro-transcribe [--lang ko] [--engine claude,codex] [--login <claude config dir> ...]
                    [--codex <codex home>] <audio file>

Logins are read, never refreshed (renewing one here would sign Claude Code or Codex out) and never
printed. Neither is a public API: use only your own logins, for your own audio, and don't share them
with other people or servers. ChatGPT keeps the audio it receives for 30 days.

Audio: a 16 kHz mono 16-bit WAV is sent as is; anything else goes through ffmpeg, or afconvert on
a Mac. Codex gets the audio in 5-minute pieces, since longer ones come back cut short; without ffmpeg
a common format (mp3, m4a, ogg, webm, wav) under 24 MB goes to it whole.
Exit status: 0 text on stdout, 1 failed (reason on stderr), 2 usage.
"""
import argparse
import base64
import hashlib
import http.client
import json
import os
from pathlib import Path
import shutil
import socket
import ssl
import struct
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import wave

API_HOST = 'api.anthropic.com'
VOICE_PATH = '/api/ws/speech_to_text/voice_stream'
CLAUDE_VERSION = '2.1.304'  # used when `claude --version` can't be asked
RATE, CHUNK_MS = 16000, 100
PACE = 4.0  # send at 4x real time: the server transcribes at about that speed, so faster ends no sooner
IDLE_S = 10  # after CloseStream, stop when nothing has arrived for this long
KEYCHAIN = 'Claude Code-credentials'
ENGINES = ('claude', 'codex')
CHATGPT_TRANSCRIBE = 'https://chatgpt.com/backend-api/transcribe'
PIECE_S = 300  # ChatGPT drops text from audio much past 10 minutes (and fails at 25): send 5-minute pieces
QUIET_S = 15  # cut each piece at the quietest moment in its last QUIET_S seconds, not mid-word
AS_IS = {'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.mp4': 'audio/mp4',
         '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.webm': 'audio/webm'}  # what ChatGPT takes without ffmpeg
AS_IS_MAX = 24 << 20


class Failed(Exception):
    pass


def say(*a):
    print('velcro-transcribe:', *a, file=sys.stderr, flush=True)


# ---- audio ----

def wav_pcm16k(path):
    """PCM frames of a 16 kHz mono 16-bit WAV, or None for any other file."""
    try:
        with wave.open(str(path)) as w:
            if (w.getnchannels(), w.getframerate(), w.getsampwidth()) == (1, RATE, 2):
                return w.readframes(w.getnframes())
    except (wave.Error, EOFError, OSError):
        pass
    return None


# What ffmpeg may read a recording or voice memo as (demuxer names, ffmpeg -demuxers).
AUDIO_DEMUXERS = 'wav,w64,mp3,mov,matroska,ogg,flac,aac,caf,aiff,amr,wv'


def to_pcm(path):
    """Any audio file → 16 kHz mono s16le bytes."""
    pcm = wav_pcm16k(path)
    if pcm is not None:
        return pcm
    with tempfile.TemporaryDirectory(prefix='velcro-transcribe-') as tmp:
        if shutil.which('ffmpeg'):
            out = Path(tmp) / 'audio.s16'
            # Audio formats only, local files only: a playlist (.m3u8) or concat list on a share would
            # otherwise have ffmpeg read files it points to, or open URLs.
            cmd = ['ffmpeg', '-nostdin', '-v', 'error', '-y', '-protocol_whitelist', 'file',
                   '-format_whitelist', AUDIO_DEMUXERS, '-i', f'file:{path}',
                   '-vn', '-ac', '1', '-ar', str(RATE), '-f', 's16le', '-c:a', 'pcm_s16le', str(out)]
        elif shutil.which('afconvert'):
            out = Path(tmp) / 'audio.wav'
            cmd = ['afconvert', '-f', 'WAVE', '-d', f'LEI16@{RATE}', '-c', '1', str(path), str(out)]
        else:
            raise Failed('this file needs ffmpeg to read it (or give a 16 kHz mono 16-bit WAV)')
        r = subprocess.run(cmd, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=300)
        if r.returncode or not out.exists():
            raise Failed(f"couldn't read the audio ({cmd[0]}): {r.stderr.strip()[-300:]}")
        return riff_data(out) if out.suffix == '.wav' else out.read_bytes()


def riff_data(path):
    """The data chunk of a WAV afconvert wrote in the format asked for. Python's wave module can't
    read it: afconvert labels it WAVE_FORMAT_EXTENSIBLE and adds a FLLR padding chunk."""
    b = Path(path).read_bytes()
    pos = 12
    while pos + 8 <= len(b):
        kind, size = b[pos:pos + 4], struct.unpack('<I', b[pos + 4:pos + 8])[0]
        if kind == b'data':
            return b[pos + 8:pos + 8 + size]
        pos += 8 + size + (size & 1)
    raise Failed('afconvert gave a WAV without audio')


def chunks(pcm, ms=CHUNK_MS):
    size = RATE * 2 * ms // 1000
    return [pcm[i:i + size] for i in range(0, len(pcm), size)]


# ---- the smallest WebSocket client (RFC 6455) that does the job ----

def ws_frame(opcode, payload, mask=None):
    """One client frame: FIN set, masked as clients must."""
    mask = os.urandom(4) if mask is None else mask
    n = len(payload)
    head = bytes([0x80 | opcode])
    if n < 126:
        head += bytes([0x80 | n])
    elif n < 1 << 16:
        head += bytes([0x80 | 126]) + struct.pack('!H', n)
    else:
        head += bytes([0x80 | 127]) + struct.pack('!Q', n)
    return head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload))


def ws_parse(buf):
    """The frame at the start of buf → (fin, opcode, payload, bytes used), or None if it's incomplete."""
    if len(buf) < 2:
        return None
    fin, opcode = buf[0] & 0x80, buf[0] & 0x0F
    masked, n, pos = buf[1] & 0x80, buf[1] & 0x7F, 2
    if n == 126:
        if len(buf) < 4:
            return None
        n, pos = struct.unpack('!H', buf[2:4])[0], 4
    elif n == 127:
        if len(buf) < 10:
            return None
        n, pos = struct.unpack('!Q', buf[2:10])[0], 10
    key = b''
    if masked:
        if len(buf) < pos + 4:
            return None
        key, pos = buf[pos:pos + 4], pos + 4
    if len(buf) < pos + n:
        return None
    payload = buf[pos:pos + n]
    if masked:
        payload = bytes(b ^ key[i % 4] for i, b in enumerate(payload))
    return bool(fin), opcode, payload, pos + n


class WS:
    def __init__(self, host, path, headers, timeout=20):
        raw = socket.create_connection((host, 443), timeout=timeout)
        self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host)
        key = base64.b64encode(os.urandom(16)).decode()
        lines = [f'GET {path} HTTP/1.1', f'Host: {host}', 'Upgrade: websocket', 'Connection: Upgrade',
                 f'Sec-WebSocket-Key: {key}', 'Sec-WebSocket-Version: 13']
        lines += [f'{k}: {v}' for k, v in headers.items()]
        self.sock.sendall(('\r\n'.join(lines) + '\r\n\r\n').encode())
        resp = b''
        while b'\r\n\r\n' not in resp:
            got = self.sock.recv(4096)
            if not got:
                raise ConnectionError('connection closed during the handshake')
            resp += got
        head, self.buf = resp.split(b'\r\n\r\n', 1)
        status = head.split(b'\r\n', 1)[0].decode(errors='replace')
        if ' 101 ' not in status + ' ':
            raise ConnectionError('voice_stream refused the connection: ' + status)
        self.parts, self.closed, self.close_code = [], False, None

    def _send(self, frame):
        self.sock.settimeout(20)
        self.sock.sendall(frame)

    def send_text(self, s):
        self._send(ws_frame(0x1, s.encode()))

    def send_binary(self, b):
        self._send(ws_frame(0x2, b))

    def recv(self, timeout):
        """The next data message (str or bytes), None on timeout; EOFError once closed."""
        while True:
            frame = ws_parse(self.buf)
            if frame:
                fin, op, payload, used = frame
                self.buf = self.buf[used:]
                if op == 0x8:
                    self.closed = True
                    code = self.close_code = struct.unpack('!H', payload[:2])[0] if len(payload) >= 2 else 1005
                    raise EOFError(f'closed (code {code}) {payload[2:].decode(errors="replace")}'.strip())
                if op == 0x9:
                    self._send(ws_frame(0xA, payload))
                    continue
                if op == 0xA:
                    continue
                self.parts.append((op, payload))
                if not fin:
                    continue
                first = self.parts[0][0]
                data = b''.join(p for _, p in self.parts)
                self.parts = []
                return data.decode() if first == 0x1 else data
            try:
                self.sock.settimeout(timeout)
                got = self.sock.recv(65536)
            except (socket.timeout, TimeoutError, ssl.SSLWantReadError, BlockingIOError):
                return None
            if not got:
                self.closed = True
                raise EOFError('connection closed')
            self.buf += got

    def close(self):
        try:
            if not self.closed:
                self._send(ws_frame(0x8, struct.pack('!H', 1000)))
        except OSError:
            pass
        self.sock.close()


# ---- the login ----

def default_logins():
    d = os.environ.get('CLAUDE_CONFIG_DIR')
    return [Path(d)] if d else [Path.home() / '.claude']


def keychain_oauth(config_dir):
    """Claude Code on a Mac keeps the login in the keychain. Its item is "Claude Code-credentials" for
    ~/.claude, with "-<first 8 hex of sha256(dir)>" added for any other CLAUDE_CONFIG_DIR."""
    if sys.platform != 'darwin' or not shutil.which('security'):
        return None
    service = KEYCHAIN
    if Path(config_dir).expanduser() != Path.home() / '.claude':
        service += '-' + hashlib.sha256(str(config_dir).encode()).hexdigest()[:8]
    user = os.environ.get('USER') or 'claude-code-user'
    r = subprocess.run(['security', 'find-generic-password', '-a', user, '-w', '-s', service],
                       stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=60)
    if r.returncode:
        return None
    try:
        return json.loads(r.stdout).get('claudeAiOauth') or None
    except ValueError:
        return None


def file_oauth(config_dir):
    try:
        return json.loads((Path(config_dir) / '.credentials.json').read_text()).get('claudeAiOauth') or None
    except (OSError, ValueError, AttributeError):
        return None


def claude_token(logins, now=None, sources=(file_oauth, keychain_oauth)):
    """(access token, login folder) from the first login that hasn't expired, trying the file and then
    the keychain for each folder (a stale file can sit next to the keychain login Claude Code uses).
    The token stays in this process: never printed, written, or renewed. Raises Failed otherwise."""
    now = time.time() if now is None else now
    expired = []
    for d in logins:
        for read in sources:
            oauth = read(d)
            if not oauth or not oauth.get('accessToken'):
                continue
            if (oauth.get('expiresAt') or 0) / 1000 > now + 120:
                return oauth['accessToken'], str(d)
            if str(d) not in expired:
                expired.append(str(d))
    if expired:
        raise Failed(f"the Claude Code login in {', '.join(expired)} has expired. velcro doesn't renew it "
                     '(that would sign Claude Code out); use Claude Code there once and try again.')
    raise Failed(f"no Claude Code login in {', '.join(map(str, logins))}. Sign in to Claude Code (claude, then "
                 '/login) with your Claude account first.')


def claude_version():
    # velcro.app's PATH leaves out ~/.local/bin, where Claude Code's installer puts it.
    claude = shutil.which('claude') or str(Path.home() / '.local/bin/claude')
    try:
        r = subprocess.run([claude, '--version'], stdin=subprocess.DEVNULL, capture_output=True,
                           text=True, timeout=10)
        v = r.stdout.split()[0] if r.returncode == 0 and r.stdout.strip() else ''
        if v[:1].isdigit():
            return v
    except (OSError, subprocess.SubprocessError):
        pass
    return CLAUDE_VERSION


# ---- voice_stream ----

def voice_query(language):
    return urllib.parse.urlencode({
        'encoding': 'linear16', 'sample_rate': str(RATE), 'channels': '1', 'endpointing_ms': '300',
        'utterance_end_ms': '1000', 'language': language, 'use_conversation_engine': 'true'})


class Transcript:
    """Collects voice_stream messages the way Claude Code does: Text/Interim replace the utterance in
    progress, Endpoint makes it final."""

    def __init__(self):
        self.final, self.pending, self.endpoint_after_close, self.error = [], '', False, None

    def feed(self, message, closing=False):
        try:
            msg = json.loads(message)
        except (TypeError, ValueError):
            return
        if not isinstance(msg, dict):
            return
        kind = msg.get('type')
        if kind in ('TranscriptText', 'TranscriptInterim'):
            if msg.get('data'):
                self.pending = msg['data']
        elif kind == 'TranscriptEndpoint':
            self.flush()
            if closing:
                self.endpoint_after_close = True
        elif kind == 'TranscriptError':
            self.flush()
            self.error = msg.get('description') or msg.get('error_code') or 'unknown transcription error'
        elif kind == 'error':
            self.flush()
            self.error = msg.get('message') or 'server error'

    def flush(self):
        if self.pending.strip():
            self.final.append(self.pending.strip())
        self.pending = ''

    def text(self):
        return ' '.join(self.final + ([self.pending.strip()] if self.pending.strip() else []))


def transcribe(pcm, logins, language, pace=PACE):
    token, login = claude_token(logins)
    say(f'{len(pcm) / RATE / 2:.1f}s of audio, Claude Code login in {login}')
    headers = {'Authorization': 'Bearer ' + token, 'User-Agent': f'claude-cli/{claude_version()} (external, cli)',
               'x-app': 'cli', 'anthropic-client-platform': 'claude_code_cli'}
    try:
        ws = WS(API_HOST, f'{VOICE_PATH}?{voice_query(language)}', headers)
    except OSError as e:
        raise Failed(f"couldn't reach voice_stream: {e}")
    del token, headers
    tr = Transcript()
    try:
        # Losing the stream before all the audio went out means the text would be cut short: fail,
        # so the fallback (if any) gets the whole file.
        try:
            ws.send_text('{"type":"KeepAlive"}')
            step, start, parts = CHUNK_MS / 1000 / pace, time.monotonic(), chunks(pcm)
            for i, chunk in enumerate(parts):
                ws.send_binary(chunk)
                while (msg := ws.recv(0)) is not None:
                    tr.feed(msg)
                delay = start + (i + 1) * step - time.monotonic()
                if delay > 0:
                    time.sleep(delay)
            ws.send_text('{"type":"CloseStream"}')
        except (OSError, EOFError) as e:
            raise Failed(f'voice_stream connection lost while sending the audio: {e}')
        # Wait while text keeps coming; stop at the final endpoint, on close, or after IDLE_S of nothing.
        cap, idle = time.monotonic() + 30 + len(pcm) / RATE / 2, time.monotonic() + IDLE_S
        while not tr.endpoint_after_close and time.monotonic() < min(cap, idle):
            try:
                msg = ws.recv(1.0)
            except EOFError as e:
                # A server error (or restart) before the final endpoint: the text is likely cut short, so fail
                # rather than pass on part of it as the whole. A normal close keeps the text so far.
                if ws.close_code not in (None, 1000, 1005):
                    raise Failed(f'voice_stream ended before the text was complete: {e}')
                break
            if msg is not None:
                tr.feed(msg, closing=True)
                idle = time.monotonic() + IDLE_S
    except OSError as e:
        raise Failed(f'voice_stream connection lost: {e}')
    finally:
        ws.close()
    if tr.error:
        raise Failed('voice_stream: ' + tr.error)
    if not tr.endpoint_after_close:
        say('no final endpoint from voice_stream; using the text so far')
    return tr.text()


# ---- codex: ChatGPT's transcribe endpoint with Codex's ChatGPT login ----

def jwt_claims(token):
    """The payload of a JWT, unverified (only to read exp and the account), or {}."""
    try:
        part = token.split('.')[1]
        return json.loads(base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)))
    except (IndexError, ValueError, TypeError):
        return {}


def default_codex_home():
    return Path(os.environ.get('CODEX_HOME') or Path.home() / '.codex')


def codex_login(home, now=None):
    """(access token, ChatGPT account id) from the ChatGPT login Codex keeps in <home>/auth.json. Read
    only: never printed, written, or renewed (renewing rotates the refresh token and signs Codex out).
    An API key in the same file is never used. Raises Failed otherwise."""
    now = time.time() if now is None else now
    path = Path(home) / 'auth.json'
    try:
        auth = json.loads(path.read_text())
    except FileNotFoundError:
        raise Failed(f'no Codex login in {path}. Sign in to Codex (codex login) with your ChatGPT account first.')
    except (OSError, ValueError) as e:
        raise Failed(f"couldn't read the Codex login in {path}: {type(e).__name__}")
    tokens = auth.get('tokens') if isinstance(auth, dict) else None
    token = tokens.get('access_token') if isinstance(tokens, dict) else None
    if not token:
        raise Failed(f'the Codex login in {path} is not a ChatGPT sign-in (an API key is never used). '
                     'Sign in to Codex with your ChatGPT account (codex login).')
    claims = jwt_claims(token)
    account = tokens.get('account_id') or (claims.get('https://api.openai.com/auth') or {}).get('chatgpt_account_id')
    if not account:
        raise Failed(f'the Codex login in {path} has no ChatGPT account; sign in to Codex again (codex login).')
    if (claims.get('exp') or 0) <= now + 120:
        raise Failed(f"the Codex login in {path} has expired. velcro doesn't renew it (that would sign Codex out); "
                     'use Codex once and try again.')
    return token, account


def wav_bytes(pcm):
    """16 kHz mono 16-bit PCM as a WAV file."""
    return (b'RIFF' + struct.pack('<I', 36 + len(pcm)) + b'WAVEfmt ' + struct.pack('<IHHIIHH', 16, 1, 1, RATE, RATE * 2, 2, 16)
            + b'data' + struct.pack('<I', len(pcm)) + pcm)


def quietest(pcm, start, end, window=RATE // 10):
    """The sample index in [start, end) where the quietest 100 ms window begins."""
    best, at = None, start
    for i in range(start, max(start + 1, end - window), window // 2):
        frame = pcm[2 * i:2 * (i + window)]
        loud = sum(abs(v) for v in struct.unpack(f'<{len(frame) // 2}h', frame[:len(frame) // 2 * 2]))
        if best is None or loud < best:
            best, at = loud, i
    return at


def pieces(pcm, seconds=PIECE_S, quiet=QUIET_S):
    """PCM cut into pieces of at most `seconds`, each ending at a quiet moment near its end."""
    total, size, out, pos = len(pcm) // 2, seconds * RATE, [], 0
    while total - pos > size:
        cut = quietest(pcm, pos + size - quiet * RATE, pos + size)
        cut = cut if cut > pos else pos + size
        out.append(pcm[2 * pos:2 * cut])
        pos = cut
    if out and total - pos < RATE:  # a sliver under a second rides with the piece before it
        out[-1] += pcm[2 * pos:]
    else:
        out.append(pcm[2 * pos:])
    return out


def multipart(fields, name, filename, content_type, data):
    boundary = uuid.uuid4().hex
    body = b''.join(f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
                    for k, v in fields.items())
    body += (f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
             f'Content-Type: {content_type}\r\n\r\n').encode() + data + f'\r\n--{boundary}--\r\n'.encode()
    return f'multipart/form-data; boundary={boundary}', body


def chatgpt_transcribe(token, account, filename, content_type, data, opener=urllib.request.urlopen):
    """The text of one audio file. ChatGPT works out the language itself (a language field is ignored)."""
    ctype, body = multipart({}, 'file', filename, content_type, data)
    req = urllib.request.Request(CHATGPT_TRANSCRIBE, data=body, method='POST', headers={
        'Authorization': 'Bearer ' + token, 'ChatGPT-Account-Id': account, 'Content-Type': ctype,
        'originator': 'codex_cli_rs', 'User-Agent': 'velcro-transcribe'})
    try:
        with opener(req, timeout=300) as r:
            reply = json.loads(r.read())
    except urllib.error.HTTPError as e:
        detail = e.read()[:300].decode(errors='replace').strip()
        raise Failed(f'ChatGPT transcribe answered {e.code}' + (f': {detail}' if detail else '')
                     + (' (sign in to Codex again)' if e.code in (401, 403) else ''))
    except (OSError, ValueError, http.client.HTTPException) as e:
        raise Failed(f"couldn't reach ChatGPT transcribe: {type(e).__name__} {e}".strip())
    if not isinstance(reply, dict) or not isinstance(reply.get('text'), str):
        raise Failed('ChatGPT transcribe gave no text')
    return reply['text'].strip()


def codex_transcribe(audio, home, send=chatgpt_transcribe):
    token, account = codex_login(home)
    try:
        pcm = audio.pcm()
    except Failed as why:
        # No ffmpeg: a short file in a format ChatGPT reads can still go whole.
        ctype, size = AS_IS.get(audio.path.suffix.lower()), audio.path.stat().st_size
        if not ctype or size > AS_IS_MAX:
            raise why
        say(f'{str(why).rstrip(".")}; sending the file whole ({size >> 10} KB) to ChatGPT with the Codex login in {home}. '
            'Past 10 minutes the text may come back cut short.')
        return send(token, account, audio.path.name, ctype, audio.path.read_bytes())
    parts = pieces(pcm)
    say(f'{len(pcm) / RATE / 2:.1f}s of audio, Codex login in {home}' + (f', {len(parts)} pieces' if len(parts) > 1 else ''))
    texts = [send(token, account, f'{audio.path.stem}-{i + 1}.wav', 'audio/wav', wav_bytes(part))
             for i, part in enumerate(parts)]
    return ' '.join(t for t in texts if t)


class Audio:
    """The file, and its PCM once an engine asks for it (converted once, failure remembered)."""

    def __init__(self, path):
        self.path, self._pcm, self._failed = Path(path), None, None

    def pcm(self):
        if self._failed:
            raise self._failed
        if self._pcm is None:
            try:
                self._pcm = to_pcm(self.path)
            except Failed as e:
                self._failed = e
                raise
        return self._pcm


def run_engines(audio, engines, run):
    """(engine, text) from the first engine in order that gives text; Failed with every reason otherwise."""
    reasons = []
    for i, name in enumerate(engines):
        try:
            text = run[name](audio)
            if text.strip():
                return name, text
            raise Failed('no speech heard')
        except Failed as e:
            reasons.append(f'{name}: {e}')
            if i + 1 < len(engines):
                say(f"{name}: {str(e).rstrip('.')}; trying {engines[i + 1]}")
    raise Failed(reasons[0] if len(reasons) == 1 else 'no engine could transcribe it. ' + ' / '.join(reasons))


def engine_list(s):
    names = [n.strip() for n in s.split(',') if n.strip()]
    if not names or any(n not in ENGINES for n in names) or len(set(names)) < len(names):
        raise argparse.ArgumentTypeError(f'a comma-separated list of {", ".join(ENGINES)}')
    return names


def main(argv=None):
    p = argparse.ArgumentParser(prog='velcro-transcribe', description='Audio file to text with your Claude Code or Codex login.')
    p.add_argument('file')
    p.add_argument('--lang', default='en', help='language spoken, e.g. en, ko, ja (en); codex works it out itself')
    p.add_argument('--engine', type=engine_list, default=list(ENGINES),
                   help='engines to try, in order: claude, codex or both (claude,codex)')
    p.add_argument('--login', action='append', type=lambda s: Path(s).expanduser(),
                   help='Claude Code config folder to take the login from; repeat to try several '
                        '(default: $CLAUDE_CONFIG_DIR or ~/.claude)')
    p.add_argument('--codex', type=lambda s: Path(s).expanduser(), default=None,
                   help='Codex home with auth.json (default: $CODEX_HOME or ~/.codex)')
    p.add_argument('--engine-file', help='write the name of the engine that gave the text here')
    a = p.parse_args(argv)
    path = Path(a.file).expanduser()
    if not path.is_file():
        say(f'no such file: {path}')
        return 1
    run = {'claude': lambda audio: transcribe(audio.pcm(), a.login or default_logins(), a.lang),
           'codex': lambda audio: codex_transcribe(audio, a.codex or default_codex_home())}
    try:
        engine, text = run_engines(Audio(path), a.engine, run)
    except Failed as e:
        say(e)
        return 1
    if a.engine_file:
        Path(a.engine_file).write_text(engine + '\n')
    print(text.strip())
    return 0


if __name__ == '__main__':
    sys.exit(main())
