openapi: 3.1.0
info:
  title: TrackScore API
  version: "1.0.0"
  summary: Programmatic electronic-music track analysis — scores, grade, genre fit, and the Klaus diagnosis.
  description: |
    The TrackScore API analyzes electronic-music audio and returns a structured verdict:
    a 0–100 hit-potential score, a 13-grade letter (A+ to F), genre fit, frequency-band
    balance, dynamics, stereo metrics, and the **Klaus** diagnosis — a plain-language
    assessment with an improvement plan.

    **Zero-storage privacy.** Audio is processed in memory and is never persisted to any
    disk, object store, or queue. Raw bytes are received, analyzed, and garbage-collected.

    **How it works (two steps).**
    1. `POST /analyses` with the file metadata and an `Idempotency-Key`. You receive a
       `202` with an `analysis.id` and a short-lived `upload` target.
    2. `POST` the raw audio bytes to `upload.url` with the returned `upload.token`.

    Then either poll `GET /analyses/{id}` until `status` is `complete`, or subscribe to
    the `analysis.completed` webhook. Scores land first (`analysis.scores_ready`); the full
    Klaus narrative fills in shortly after (`analysis.completed`).

    **Test mode.** A key prefixed `ts_test_` returns a deterministic canned analysis,
    never touches the analysis engine, and never spends a credit — use it to build and
    verify your integration for free.
  contact:
    name: TrackScore Developer Support
    url: https://trackscore.ai/developers
  termsOfService: https://trackscore.ai/developers/terms

servers:
  - url: https://trackscore.ai/api/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Analyses
    description: Submit audio and retrieve results.
  - name: Genres
    description: The supported electronic-music genre profiles.
  - name: Embed
    description: The public score-badge widget.

paths:
  /analyses:
    post:
      tags: [Analyses]
      operationId: createAnalysis
      summary: Submit a track for analysis
      description: |
        Creates an analysis and returns an upload target. Spends 1 credit for a
        credit-billed key (0 in test mode). Requires an `Idempotency-Key`; a repeat
        with the same key and body within 24 hours replays the original response
        without spending another credit.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAnalysisRequest'
      responses:
        '202':
          description: Accepted. Analysis created; upload the bytes to `upload.url`.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalysisAccepted' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '413': { $ref: '#/components/responses/PayloadTooLarge' }
        '422': { $ref: '#/components/responses/UnprocessableAudio' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    get:
      tags: [Analyses]
      operationId: listAnalyses
      summary: List your analyses
      description: Cursor-paginated list of the analyses created by the calling key.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: starting_after
          in: query
          description: Return results after this analysis id (older).
          schema: { type: string }
        - name: ending_before
          in: query
          description: Return results before this analysis id (newer).
          schema: { type: string }
      responses:
        '200':
          description: A paginated list of analyses.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalysisList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /analyses/{id}:
    get:
      tags: [Analyses]
      operationId: getAnalysis
      summary: Retrieve an analysis
      description: |
        Returns the full analysis. While `status` is `processing`, the score fields are
        absent. When `status` is `complete` and `diagnosis_status` is `pending`, the
        scores and a template diagnosis are present and the enhanced Klaus narrative is
        still being generated; it lands when `diagnosis_status` becomes `ready`.
      parameters:
        - $ref: '#/components/parameters/AnalysisId'
      responses:
        '200':
          description: The analysis.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Analysis' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /analyses/{id}/status:
    get:
      tags: [Analyses]
      operationId: getAnalysisStatus
      summary: Retrieve lightweight status
      description: A cheap poll target that returns only the status fields.
      parameters:
        - $ref: '#/components/parameters/AnalysisId'
      responses:
        '200':
          description: The analysis status.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalysisStatus' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /genres:
    get:
      tags: [Genres]
      operationId: listGenres
      summary: List genre profiles
      description: The electronic-music genre profiles TrackScore scores against.
      responses:
        '200':
          description: The genre catalog.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GenreList' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /embed/{id}:
    get:
      tags: [Embed]
      operationId: getEmbed
      summary: Render the score badge
      description: |
        Returns an embeddable score badge (overall score, grade, and genre only — never
        audio, never the diagnosis text). Served only for a completed analysis whose owner
        has explicitly enabled embedding; otherwise `404`. No API key required.
      security: []
      parameters:
        - $ref: '#/components/parameters/AnalysisId'
        - name: theme
          in: query
          schema: { type: string, enum: [dark, light], default: dark }
        - name: size
          in: query
          schema: { type: string, enum: [sm, md], default: md }
      responses:
        '200':
          description: An HTML/SVG score badge.
          content:
            text/html:
              schema: { type: string }
        '404': { $ref: '#/components/responses/NotFound' }

webhooks:
  analysis.scores_ready:
    post:
      operationId: onAnalysisScoresReady
      summary: Scores are ready (diagnosis still generating)
      description: Fired when an analysis reaches `complete` with scores available. `diagnosis_status` is `pending`.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AnalysisEvent' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  analysis.completed:
    post:
      operationId: onAnalysisCompleted
      summary: Analysis fully complete (Klaus diagnosis ready)
      description: Fired when the enhanced Klaus narrative has landed. `diagnosis_status` is `ready`.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AnalysisEvent' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  analysis.failed:
    post:
      operationId: onAnalysisFailed
      summary: Analysis failed
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AnalysisFailedEvent' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }
  credit.balance.low:
    post:
      operationId: onCreditBalanceLow
      summary: Credit balance is low
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreditBalanceLowEvent' }
      responses:
        '200': { description: Return any 2xx to acknowledge. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Send your secret key as `Authorization: Bearer ts_live_...`. Use a `ts_test_`
        key for the sandbox. Keys are shown once at creation — store them securely and
        never expose them in client-side code.

  parameters:
    AnalysisId:
      name: id
      in: path
      required: true
      description: The analysis id.
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: A unique key (<=255 chars) that makes the request safely retryable. Reusing it within 24h replays the original result.
      schema: { type: string, maxLength: 255 }

  headers:
    X-Request-Id:
      description: A unique id for this request, useful for support.
      schema: { type: string }
    X-Credits-Remaining:
      description: Remaining credits for a credit-billed key (omitted for comp/metered/test keys).
      schema: { type: integer }
    X-RateLimit-Limit:
      description: The request-rate ceiling per minute.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix time when the window resets.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying.
      schema: { type: integer }

  responses:
    BadRequest:
      description: The request was malformed.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    Unauthorized:
      description: The API key is missing, invalid, or revoked.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    Forbidden:
      description: The key is not eligible for this operation.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    NotFound:
      description: No such resource, or it is not accessible to this key.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    InsufficientCredits:
      description: The account has no credits remaining.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    IdempotencyConflict:
      description: The `Idempotency-Key` was reused with a different body, or a request with that key is still in progress.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    PayloadTooLarge:
      description: The file exceeds the size or duration limit.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    UnprocessableAudio:
      description: The audio could not be decoded or analyzed.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    RateLimited:
      description: A rate, quota, or concurrency limit was exceeded.
      headers:
        X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
        Retry-After: { $ref: '#/components/headers/Retry-After' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    ServiceUnavailable:
      description: The analysis service is temporarily unavailable.
      headers: { X-Request-Id: { $ref: '#/components/headers/X-Request-Id' } }
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }

  schemas:
    CreateAnalysisRequest:
      type: object
      required: [file_name]
      properties:
        file_name:
          type: string
          description: The audio file name (used for metadata; the extension informs format detection).
          example: my_track.wav
        mastering_status:
          type: string
          enum: [pre-master, master]
          default: pre-master
          description: Whether the track is a pre-master or a finished master. Affects scoring thresholds.
        external_id:
          type: string
          maxLength: 255
          description: Your own reference id, echoed back on the analysis and every webhook.
          example: catalog-8842

    UploadTarget:
      type: object
      description: Where to send the raw audio bytes (step 2).
      properties:
        url: { type: string, format: uri, example: "https://worker.trackscore.ai/analyze" }
        method: { type: string, example: POST }
        field: { type: string, example: file, description: The multipart form field name for the bytes. }
        token: { type: string, description: A short-lived bearer token for the upload. }
        expires_in: { type: integer, example: 600, description: Token lifetime in seconds. }

    AnalysisAccepted:
      type: object
      description: The response to a submission — the analysis plus its upload target.
      properties:
        object: { type: string, const: analysis }
        id: { type: string, format: uuid }
        status: { type: string, enum: [processing] }
        diagnosis_status: { type: string, enum: [pending] }
        livemode: { type: boolean }
        external_id: { type: string, nullable: true }
        credits_remaining: { type: integer, nullable: true, description: Present for credit-billed keys. }
        upload: { $ref: '#/components/schemas/UploadTarget' }

    AnalysisStatus:
      type: object
      properties:
        object: { type: string, const: analysis.status }
        id: { type: string, format: uuid }
        status: { type: string, enum: [processing, complete, failed] }
        diagnosis_status: { type: string, enum: [pending, ready] }
        livemode: { type: boolean }

    Analysis:
      type: object
      description: A complete track analysis.
      properties:
        object: { type: string, const: analysis }
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [processing, complete, failed]
        diagnosis_status:
          type: string
          enum: [pending, ready]
          description: |
            `pending` while the enhanced Klaus narrative is still generating (a template
            diagnosis is already present); `ready` once the full narrative has landed.
        livemode: { type: boolean }
        external_id: { type: string, nullable: true }
        file_name: { type: string }
        mastering_status: { type: string, enum: [pre-master, master] }
        created_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time, nullable: true }
        duration_seconds: { type: number, description: Track length in seconds. }
        score: { type: integer, minimum: 0, maximum: 100, description: The headline hit-potential score. }
        grade: { $ref: '#/components/schemas/Grade' }
        scores: { $ref: '#/components/schemas/Scores' }
        tempo: { $ref: '#/components/schemas/Tempo' }
        key: { oneOf: [ { $ref: '#/components/schemas/Key' }, { type: 'null' } ] }
        genre: { $ref: '#/components/schemas/Genre' }
        frequency: { $ref: '#/components/schemas/Frequency' }
        mix: { $ref: '#/components/schemas/Mix' }
        structure: { $ref: '#/components/schemas/Structure' }
        danceability: { $ref: '#/components/schemas/Danceability' }
        energy: { $ref: '#/components/schemas/Energy' }
        diagnosis: { $ref: '#/components/schemas/Diagnosis' }
        feedback:
          type: array
          items: { $ref: '#/components/schemas/Feedback' }
        error:
          description: Present only when `status` is `failed`.
          oneOf: [ { $ref: '#/components/schemas/Error' }, { type: 'null' } ]

    Grade:
      type: object
      description: The letter grade derived from the score (A+ to F).
      properties:
        letter: { type: string, example: "B+" }
        label: { type: string, example: "Very Good" }

    Scores:
      type: object
      description: The six pillar scores (0–100).
      properties:
        hit_potential: { type: integer, minimum: 0, maximum: 100 }
        danceability: { type: integer, minimum: 0, maximum: 100 }
        mix_quality: { type: integer, minimum: 0, maximum: 100 }
        frequency_balance: { type: integer, minimum: 0, maximum: 100 }
        energy: { type: integer, minimum: 0, maximum: 100 }
        structure: { type: integer, minimum: 0, maximum: 100 }

    Tempo:
      type: object
      properties:
        bpm: { type: number, example: 124.0 }
        regularity: { type: number, description: Beat regularity, 0–1. }

    Key:
      type: object
      properties:
        key: { type: string, example: "A" }
        scale: { type: string, example: "minor" }
        camelot: { type: string, example: "8A" }
        confidence: { type: number }

    Genre:
      type: object
      properties:
        detected: { type: string, example: deep_house }
        name: { type: string, example: "Deep House" }
        confidence: { type: number }

    FrequencyBands:
      type: object
      description: Percentage of spectral energy per band.
      properties:
        sub_bass: { type: number }
        bass: { type: number }
        low_mid: { type: number }
        mid: { type: number }
        upper_mid: { type: number }
        high: { type: number }
        air: { type: number }

    Frequency:
      type: object
      properties:
        bands: { $ref: '#/components/schemas/FrequencyBands' }
        ideal: { $ref: '#/components/schemas/FrequencyBands' }
        centroid_hz: { type: number }
        score: { type: integer, minimum: 0, maximum: 100 }

    Mix:
      type: object
      properties:
        loudness_lufs: { type: number }
        dynamic_range_db: { type: number }
        dynamic_complexity: { type: number }
        crest_factor_db: { type: number }
        peak_dbfs: { type: number }
        stereo_width: { type: number }
        stereo_correlation: { type: number }
        clipping_pct: { type: number }
        score: { type: integer, minimum: 0, maximum: 100 }

    StructureSection:
      type: object
      properties:
        start: { type: number }
        end: { type: number }
        label: { type: string, example: intro }
        energy: { type: integer }

    Structure:
      type: object
      properties:
        sections:
          type: array
          items: { $ref: '#/components/schemas/StructureSection' }
        score: { type: integer, minimum: 0, maximum: 100 }

    DanceabilityBreakdown:
      type: object
      properties:
        tempo_fitness: { type: integer }
        beat_strength: { type: integer }
        rhythmic_energy: { type: integer }
        bass_presence: { type: integer }
        groove_clarity: { type: integer }
        essentia_dance: { type: integer }

    Danceability:
      type: object
      properties:
        breakdown: { $ref: '#/components/schemas/DanceabilityBreakdown' }
        score: { type: integer, minimum: 0, maximum: 100 }

    Energy:
      type: object
      properties:
        score: { type: integer, minimum: 0, maximum: 100 }
        rms_mean: { type: number }
        onset_rate: { type: number }

    Diagnosis:
      type: object
      description: The Klaus diagnosis — a plain-language assessment of the track.
      properties:
        status: { type: string, enum: [pending, ready] }
        summary: { type: string, description: The overall verdict. }
        frequency: { type: string, nullable: true }
        mix: { type: string, nullable: true }
        danceability: { type: string, nullable: true }

    Feedback:
      type: object
      properties:
        type: { type: string, enum: [warn, tip, pass] }
        category: { type: string, example: frequency }
        text: { type: string }

    AnalysisList:
      type: object
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/Analysis' }
        has_more: { type: boolean }
        next_cursor: { type: string, nullable: true }

    GenreProfile:
      type: object
      properties:
        key: { type: string, example: deep_house }
        name: { type: string, example: "Deep House" }
        bpm_range:
          type: array
          items: { type: number }
          minItems: 2
          maxItems: 2
          example: [118, 126]
        ideal_bands: { $ref: '#/components/schemas/FrequencyBands' }

    GenreList:
      type: object
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/GenreProfile' }

    Error:
      type: object
      properties:
        type:
          type: string
          enum: [invalid_request_error, authentication_error, permission_error, rate_limit_error, api_error]
        code:
          type: string
          description: A machine-stable error code.
          example: insufficient_credits
        message: { type: string }
        param: { type: string, nullable: true }
        doc_url: { type: string, nullable: true }
        request_id: { type: string }

    ErrorEnvelope:
      type: object
      properties:
        error: { $ref: '#/components/schemas/Error' }

    Event:
      type: object
      description: The base webhook event envelope.
      properties:
        id: { type: string, format: uuid, description: Stable event id — dedupe on this. }
        object: { type: string, const: event }
        type: { type: string }
        created: { type: integer, description: Unix time. }
        livemode: { type: boolean }

    AnalysisEvent:
      allOf:
        - $ref: '#/components/schemas/Event'
        - type: object
          properties:
            type: { type: string, enum: [analysis.scores_ready, analysis.completed] }
            data: { $ref: '#/components/schemas/Analysis' }

    AnalysisFailedEvent:
      allOf:
        - $ref: '#/components/schemas/Event'
        - type: object
          properties:
            type: { type: string, const: analysis.failed }
            data:
              type: object
              properties:
                id: { type: string, format: uuid }
                status: { type: string, const: failed }
                external_id: { type: string, nullable: true }
                error: { $ref: '#/components/schemas/Error' }

    CreditBalanceLowEvent:
      allOf:
        - $ref: '#/components/schemas/Event'
        - type: object
          properties:
            type: { type: string, const: credit.balance.low }
            data:
              type: object
              properties:
                credits_remaining: { type: integer }
                threshold: { type: integer }
