;;; bytebeat.el --- Play and compose bytebeats in Emacs -*- lexical-binding: t; -*-

;; Copyright (C) 2026 Mikhail Kirillov

;; Author: Mikhail Kirillov <w96k@runbox.com>
;; Version: 0.1.0
;; Package-Requires: ((emacs "27.1"))
;; Keywords: multimedia music bytebeat
;; URL: https://git.wkz-devel.org/w96k/bytebeat.el
;; SPDX-License-Identifier: GPL-3.0-or-later

(require 'bytebeat-collection)

(defgroup bytebeat nil
  "Compile and play bytebeat music from Emacs."
  :group 'multimedia
  :prefix "bytebeat-")

(defcustom bytebeat-directory (expand-file-name "bytebeat/" user-emacs-directory)
  "Directory where the compiled bytebeat binary is stored."
  :type 'directory
  :group 'bytebeat)

(defcustom bytebeat-source-file
  (expand-file-name "bytebeat.c"
                    (expand-file-name "bytebeat/" user-emacs-directory))
  "Path to the bytebeat C source."
  :type 'file
  :group 'bytebeat)

(defcustom bytebeat-binary-name "bytebeat"
  "Name of the compiled bytebeat binary."
  :type 'string
  :group 'bytebeat)

(defcustom bytebeat-formula "(t * (((t >> 12) | (t >> 8)) & (63 & (t >> 4)))) & 0xFF"
  "Current bytebeat formula expressed in C, using `t' as the time variable.
Should not contain `c = (int)' or trailing `;'."
  :type 'string
  :group 'bytebeat)

(defvar bytebeat-c-source "
/* bytebeat.c - bytebeat compiler and player
 * Outputs unsigned 8-bit PCM at 8000 Hz to stdout.
 * Compile: gcc -O2 -o bytebeat bytebeat.c
 * Run:     ./bytebeat > /dev/audio
 *      or: ./bytebeat | aplay -r 8000 -f U8 -c 1
 */

#include <stdio.h>
#include <stdlib.h>

int
main (int argc, char **argv)
{
  unsigned long t = 0;
  int c;

  /* limit duration in seconds (default: infinite) */
  unsigned long max = (argc > 1) ? (unsigned long) atoi (argv[1]) * 8000UL : 0;

  for (;;)
    {
      /* DON'T REMOVE THIS COMMENT ;;;FORMULA */
      c = (int) ((t * (((t >> 12) | (t >> 8)) & (63 & (t >> 4)))) & 0xFF);

      if (putchar (c) == EOF)
        break;
      t++;
      if (max && t >= max)
        break;
    }
  return 0;
}
"
  "Embedded bytebeat C source code.")

(defun bytebeat--binary-path ()
  "Return the full path to the compiled bytebeat binary."
  (expand-file-name bytebeat-binary-name bytebeat-directory))

(defcustom bytebeat-compile-command "gcc -O2 -o %s %s"
  "Format string for the compile command.
The first %s is replaced with the binary path, the second with the source file."
  :type 'string
  :group 'bytebeat)

(defcustom bytebeat-play-command "%s %s | aplay -q -r 8000 -f U8 -c 1"
  "Format string for the play command.
The first %s is replaced with the binary path, the second with the duration argument."
  :type 'string
  :group 'bytebeat)

(defcustom bytebeat-default-duration 15
  "Default duration in seconds when prompting for play length."
  :type 'integer
  :group 'bytebeat)

(defcustom bytebeat-compile-delay 1
  "Delay in seconds between compilation and playback in `bytebeat-compile-and-play'."
  :type 'number
  :group 'bytebeat)

(defvar bytebeat-process nil
  "Currently running bytebeat process, if any.")

(defun bytebeat--update-c-source (formula)
  "Update `bytebeat-c-source' with a new bytebeat formula"
  (let* ((marker ";;;FORMULA")
         (case-fold-search nil)
         (lines (split-string bytebeat-c-source "\n"))
         (result '())
         (replace-next nil))
    (dolist (line lines)
      (cond
       (replace-next
        (let* ((indent (if (string-match "^\\([ \t]*\\)" line)
                           (match-string 1 line)
                         "      ")))
          (push (format "%sc = (int) (%s);" indent formula) result))
        (setq replace-next nil))
       (t
        (push line result)
        (when (string-match-p marker line)
          (setq replace-next t)))))
    (setq bytebeat-c-source (mapconcat #'identity (nreverse result) "\n"))))

(defun bytebeat-read-source ()
  "Read `bytebeat-source-file' from disk and update `bytebeat-c-source'.
If the file does not exist, leave `bytebeat-c-source' unchanged and signal
a message.  Returns the new value of `bytebeat-c-source'."
  (interactive)
  (if (file-readable-p bytebeat-source-file)
      (progn
        (setq bytebeat-c-source
              (with-temp-buffer
                (insert-file-contents bytebeat-source-file)
                (buffer-string)))
        (message "Read bytebeat source from %s" bytebeat-source-file)
        bytebeat-c-source)
    (message "Bytebeat source file %s does not exist" bytebeat-source-file)
    bytebeat-c-source))

(defun bytebeat-write-source ()
  "Write the embedded C source to `bytebeat-source-file'.
Creates `bytebeat-directory' if it does not exist."
  (interactive)
  (unless (file-directory-p bytebeat-directory)
    (make-directory bytebeat-directory t))
  (with-temp-file bytebeat-source-file
    (insert bytebeat-c-source))
  (message "Wrote bytebeat source to %s" bytebeat-source-file))

(defun bytebeat--compile-sync ()
  "Synchronously compile the bytebeat source.
Returns t on success, signals an error on failure."
  (unless (file-directory-p bytebeat-directory)
    (make-directory bytebeat-directory t))
  (bytebeat-write-source)
  (let* ((cmd (format bytebeat-compile-command
                      (shell-quote-argument (bytebeat--binary-path))
                      (shell-quote-argument (expand-file-name bytebeat-source-file))))
         (buffer (get-buffer-create "*bytebeat-compile*"))
         (exit-code
          (let ((default-directory bytebeat-directory))
            (with-current-buffer buffer
              (erase-buffer))
            (call-process-shell-command cmd nil buffer t))))
    (unless (zerop exit-code)
      (display-buffer buffer)
      (error "Bytebeat compilation failed (exit %d)" exit-code))
    t))

(defun bytebeat-play (&optional seconds)
  "Play bytebeat through aplay.  With prefix arg, ask for SECONDS.
Produces `bytebeat.c', compiles it, and only then plays."
  (interactive (list (when current-prefix-arg
                       (read-number "Duration (seconds): "
                                    bytebeat-default-duration))))
  (when (file-readable-p bytebeat-source-file)
    (bytebeat-read-source))
  (bytebeat--update-c-source (read-string "Bytebeat formula (in terms of t): "
					  bytebeat-formula))
  (bytebeat--compile-sync)
  (bytebeat-stop)
  (setq bytebeat-process
        (start-process-shell-command
         "bytebeat" "*bytebeat*"
         (format bytebeat-play-command
                 (shell-quote-argument (bytebeat--binary-path))
                 (if seconds (number-to-string seconds) "")))))

(defun bytebeat--choose-from (alist prompt seconds)
  "Pick a formula from ALIST with completion using PROMPT and play it.
SECONDS, when non-nil, limits playback duration."
  (unless alist
    (error "No bytebeat formulas available"))
  (let* ((completion-extra-properties
          (list :annotation-function
                (lambda (key)
                  (let ((formula (cdr (assoc key alist))))
                    (when formula
                      (concat "  " (propertize formula 'face 'shadow)))))))
         (name (completing-read prompt
                                (mapcar #'car alist)
                                nil t))
         (formula (cdr (assoc name alist))))
    (unless formula
      (error "No formula found for `%s'" name))
    (setq bytebeat-formula formula)
    (when (file-readable-p bytebeat-source-file)
      (bytebeat-read-source))
    (bytebeat--update-c-source formula)
    (bytebeat--compile-sync)
    (bytebeat-stop)
    (message "Playing bytebeat: %s" name)
    (setq bytebeat-process
          (start-process-shell-command
           "bytebeat" "*bytebeat*"
           (format bytebeat-play-command
                   (shell-quote-argument (bytebeat--binary-path))
                   (if seconds (number-to-string seconds) ""))))))

(defun bytebeat-choose-user (&optional seconds)
  "Choose a bytebeat formula from `bytebeat-collection-user' and play it.
With prefix arg, ask for SECONDS."
  (interactive (list (when current-prefix-arg
                       (read-number "Duration (seconds): "
                                    bytebeat-default-duration))))
  (bytebeat--choose-from bytebeat-collection-user
                         "Bytebeat (user): "
                         seconds))

(defun bytebeat-choose-third-party (&optional seconds)
  "Choose a bytebeat formula from `bytebeat-collection-third-party' and play it.
With prefix arg, ask for SECONDS."
  (interactive (list (when current-prefix-arg
                       (read-number "Duration (seconds): "
                                    bytebeat-default-duration))))
  (bytebeat--choose-from bytebeat-collection-third-party
                         "Bytebeat (third-party): "
                         seconds))

(defvar bytebeat-radio-active nil
  "Non-nil when `bytebeat-radio' is currently running.")

(defun bytebeat-stop ()
  "Stop any running bytebeat process, including `bytebeat-radio'."
  (interactive)
  (setq bytebeat-radio-active nil)
  (when (and bytebeat-process (process-live-p bytebeat-process))
    (set-process-sentinel bytebeat-process nil)
    (interrupt-process bytebeat-process)
    (setq bytebeat-process nil)))

(defun bytebeat-save ()
  "Save the current `bytebeat-formula' to `bytebeat-collection-user'.
Prompts for a name and persists the updated collection via Customize."
  (interactive)
  (let* ((name (read-string "Save bytebeat as: "))
         (formula (read-string "Formula: " bytebeat-formula)))
    (when (string-empty-p name)
      (error "Name must not be empty"))
    (when (string-empty-p formula)
      (error "Formula must not be empty"))
    (let ((existing (assoc name bytebeat-collection-user)))
      (when (and existing
                 (not (yes-or-no-p
                       (format "Formula `%s' already exists.  Overwrite? " name))))
        (user-error "Aborted"))
      (if existing
          (setcdr existing formula)
        (setq bytebeat-collection-user
              (append bytebeat-collection-user
                      (list (cons name formula))))))
    (customize-save-variable 'bytebeat-collection-user
                             bytebeat-collection-user)
    (setq bytebeat-formula formula)
    (message "Saved bytebeat formula `%s'" name)))

(defun bytebeat-radio ()
  "Play every bytebeat formula (user and third-party) for `bytebeat-default-duration' seconds each."
  (interactive)
  (let ((all (append bytebeat-collection-user
                     bytebeat-collection-third-party)))
    (unless all
      (error "No bytebeat formulas available"))
    (bytebeat-stop)
    (setq bytebeat-radio-active t)
    (cl-labels
        ((play-next
           (queue)
           (cond
            ((not bytebeat-radio-active)
             (message "Bytebeat radio stopped"))
            ((null queue)
             (setq bytebeat-radio-active nil)
             (message "Bytebeat radio finished"))
            (t
             (pcase-let ((`(,name . ,formula) (car queue)))
               (setq bytebeat-formula formula)
               (when (file-readable-p bytebeat-source-file)
                 (bytebeat-read-source))
               (bytebeat--update-c-source-with-formula formula)
               (condition-case err
                   (progn
                     (bytebeat--compile-sync)
                     (setq bytebeat-process
                           (start-process-shell-command
                            "bytebeat" "*bytebeat*"
                            (format bytebeat-play-command
                                    (shell-quote-argument (bytebeat--binary-path))
                                    (number-to-string bytebeat-default-duration))))
                     (message "Bytebeat radio: now playing `%s'" name)
                     (set-process-sentinel
                      bytebeat-process
                      (lambda (_proc _event)
                        (when bytebeat-radio-active
                          (play-next (cdr queue))))))
                 (error
                  (message "Bytebeat radio: skipping `%s' (%s)"
                           name (error-message-string err))
                  (play-next (cdr queue)))))))))
      (play-next all))))

(provide 'bytebeat)
;;; bytebeat.el ends here
