Nyquist: get project folder for ACX project report plug-in

Is there any way to get the project folder in a Nyquist plug-in? When I finish narrating and processing a novel, I end up with something on the order of 40 mastered tracks, most approximately 20 minutes long, one for each chapter, plus front and back matter. I put them all into a single project so I can do Export>>Multiple, then walk away and let my machine crunch away for an hour or two–actually I run it in a virtual machine so I can continue working.

I’ve written a Nyquist plug-in that goes through all tracks and writes a tab-delimited text file that can be imported into a spreadsheet. It’s a report detailing length, peak, rms, floor, etc, for each track. I’d like to write that file into the same folder as the Audacity project, but for the life of me I can’t find a way in Nyquist to get the Project Folder.

Maybe I’m missing something simple, but any help would be appreciated.

It’s a bit tricky because the project folder is not a fixed location.

  • There’s the default folder for saving projects, which could be read with the “GetPreferences” scripting command.
  • If the project was opened from a previously saved AUP3 file, then you could get the info from Preference “[RecentFiles]”.
  • If the project hasn’t been saved yet, then the active project folder is in Audacity’s “temp” folder, which could again be read from Preferences.

If there’s more than one project open, then I don’t think there’s any reliable way to find the location of the project file, because each project shares the same Audacity Preferences and Audacity configuration files.

Probably the most useful case is when a saved project has been opened, and it is the only open project. In this case, you can read the “[RecentFiles]” section from Preferences, and capture the last (most recent) entry in the list of files. See the “LegacyMacroOutputFolder.ny” plug-in as an example of this approach: Legacy macro-output folder

Steve:
If the project has been saved, then Audacity could provide the file path and name as one of the properties in the PROJECT global property list. Otherwise it could simply return a empty string or nil. Can I request that for future consideration?
Jim

I figured out a way. This code uses (aud-get-info “menus”) to get a list of the menus, then searches through the “Recent Files” menu for an entry that starts with the Documents folder and ends with the Project Name plus “.aup” or “.aup3”. If it finds such an entry, it returns the full directory path without trailing path separator. Otherwise it returns the Documents path as a default.

If someone has recently opened a document, then it’ll be found near the top of the list. It can be defeated if the user Clears the Recent Files menu, or if the developers ever change the name of the Recent Files menu, or when the Recent Files menu is in a non-English language. But that can be handled by eliminating the test for the string “recent files” in the LABEL field. All that’ll do is prevent it from exiting sooner once it’s past the Recent Files section of the menus and it has failed.

Here’s the code:

;;;Searches the "Recent Files" menu for an entry that starts with the Documents folder
;;;and ends with the Project Name plus ".aup" or ".aup3"
;;;If it finds such an entry, it returns the full directory path without trailing path separator.
;;;Otherwise it returns the Documents path as a default
(defun getProjectFolder()
  (let* ((filePathDocs (format nil "~a" (get '*system-dir*' documents)))
          (menus (aud-get-info "menus"))
          (projFlNameOld (format nil "~a~a" (get '*project*' name) ".aup"))
          (nProjFlNameOld (length projFlNameOld))
          (projFlNameNew (format nil "~a~a" (get '*project*' name) ".aup3"))
          (nProjFlNameNew (length projFlNameNew))
          (nFilePathDocs (length filePathDocs))
          (nBoth (+ nFilePathDocs nProjFlNameNew))
          (nMenus (length menus))
          (i 0)
          (inRecents nil)        
        )
    (loop 
      (setf menu (nth i menus))                   ;get menu item i
      (setf label (second (assoc 'label menu)))   ;get label for item i
      (setf depth (second (assoc 'depth menu)))   ;get depth for item i
      ;;if inRecents, but no longer in depth 2, we've 
      ;;been through all the recents and didn't find it
      (when (and inRecents (/= depth 2))  (return filePathDocs))
      (setf nLabel (length label))
      (when (and inRecents      ;In recents
              (> nLabel nBoth)  ;Label is long enough
              ;And the first part of it matches the documents file
              (string-equal filePathDocs (subseq label 0 nFilePathDocs))
            )
        ;;If the last part of the Label also matches the new style filename
        (if (string-equal projFlNameNew (subseq label (- nLabel nProjFlNameNew) nLabel))
          (return (subseq label 0 (- nLabel (+ nProjFlNameNew 1)))) ;Return the full directory
          ;;Else if the last part of the Label also matches the old style filename
          (if (string-equal projFlNameOld (subseq label (- nLabel nProjFlNameOld) nLabel))
            (return (subseq label 0 (- nLabel (+ nProjFlNameOld 1)))) ;Return the full directory
          )
        )
      )

      (when (string-equal label "recent files") (setq inRecents t))
      (setq i (+ i 1))
      (when (>= i nMenus) (return filePathDocs))
    )
  )
)

It’s not working for me.

The approach should work (with caveats), but the code you posted is just returning the system default documents directory.

This works for me on Linux (may need tweaking for Windows - I’ve not tested)

(defun sub-in-string(sub str)
  ;;; Return T if sub is in str
  (when (or (not (stringp sub)) (not (stringp sub)))
    (return nil))
  ;; find the last path separator
  (do ((i (1- (length str)) (1- i)))
      ((= i 0) nil)
    (when (char= (char str i) *file-separator*)
      ;; Now check if names match
      (let ((fname (subseq str (+ i 1))))
        (when (> (length sub)(length fname))
          (return-from sub-in-string nil))  ;filename is too short
        (if (string= (subseq fname 0 (length sub)) sub)
            (return-from sub-in-string t)
            (return-from sub-in-string nil))))))

(defun get-recent()
  (let ((menus (aud-get-info "menus"))
        isrecent
        recent)
    (dolist (row menus nil)
      (let ((str (second (assoc 'label row))))
        (when isrecent
          (when (string= str "----")
            ;end of recent files section
            (return-from get-recent (reverse recent)))
          (push str recent))
        (when (string= str "Recent Files")
          ;start of recent files section
          (setf isrecent t))))))

(defun get-project-path()
  ;;; Return project's path or if not found
  ;;; return the system default document dir.
  (let ((project (get '*project*' name))
        (recent-files (get-recent))
        (default (get '*system-dir*' documents)))
    (when (string= project "")
      (return-from get-project-path default))
    (dolist (fname recent-files default)
      (format t "project: ~s  fname: ~s~%" project fname)
      (when (sub-in-string project fname)
        (return-from get-project-path fname)))))


(print (get-project-path))

Fwiw, this is the code from “LegacyMacroFolder.ny” that I was referring to:

(defun get-filename ()
  ;; Return most recent filename or empty string.
  (do ((i 12 (1- i)))
      ((= i 0) "")
    (if (< i 10)
      (setf fnum (format nil "0~a" i))
      (setf fnum i))
    (let ((cmd (format nil "GetPreference: Name=RecentFiles/file~a" fnum))
          fname)
      (setf fname (first (aud-do cmd)))
      (when (string/= fname "")
        (setf test-path fname)
        (return fname)))))

(print (get-filename))

I set my code up to return the default documents directory if:

  1. It doesn’t find a menu with text “recent files.”
  2. it goes through the entire list and doesn’t find a match that is:
    1a. somewhere in the documents directory or its subdirectories, and
    1b. ends with the Project Name

But I think I like your code better. Let me take a closer look at it.

The problem with get-filename() is that it only returns the path and name of the most recently opened file. If you have multiple projects open then only one project instance returns the correct file spec and all the rest incorrectly return that same file spec. I modified it to add a test to ensure that the filename–regardless of extension–matches the project name and that seems to work even if multiple projects are open. Here’s the code:

;;;Added test to ensure file name matches project name
(defun get-filename ()
  (let* (
    (fNameOld (format nil "~a~a~a" *file-separator* (get '*project*' name) ".aup"))
    (lnFNameOld (length fNameOld))
    (fNameNew (format nil "~a~a~a" *file-separator* (get '*project*' name) ".aup3"))
    (lnFNameNew (length fNameNew)))
    ;; Return most recent filename or empty string.
    (do ((i 12 (1- i)))
        ((= i 0) "")
      (if (< i 10)
        (setf fnum (format nil "0~a" i))
        (setf fnum i))
      (let ((cmd (format nil "GetPreference: Name=RecentFiles/file~a" fnum))
            fname)
        (setf fname (first (aud-do cmd)))
        (setf lnfname (length fname))
        (when (and (string/= fname "")
          (or (string-equal fNameNew (subseq fname (- lnfname lnFNameNew) lnfname))
          (string-equal fNameOld (subseq fname (- lnfname lnFNameOld) lnfname))))
          (return fname))))))

(print (get-filename))

That said, the inherent assumption that the development team will never change the maximum number of recent files bothers me. Because if they do, that’ll break this code.

I took a look at your code for get-project-path. It works nicely on Windows but not on macOS. The culprit is the “Recent Files” string in get-recent. On the mac it’s “Open Recent”. How do you test to determine if you’re on a mac?

Perhaps just change:

(when (string= str "Recent Files")

to:

(when (or (string= str "Recent Files") (string= str "Open Recent"))

It took me about an hour but I figured this out:

(defun ismacOS()
  (let* ((docsPath (format nil "~a" (get '*system-dir*' documents)))
    (usersPart (subseq docsPath 0 6)))
    (if (string-equal usersPart "/Users") t nil)))

Then on entry to the routine in a “let” statement i set a variable as follows:

(menuStr (if (ismacOS) "Open Recent" "Recent Files"))

That way it only has to make the logical test once on entry to the routine, not on every iteration through the list, which I assume is a little faster.

BTW: your code also crashed if I cleared the Recent Files List so there were no entries at all, so I made some changes, cleaned things up and got it working nicely in every scenario I could come up with. There’s now a separate routine to get the fully qualified project file name from the recents, or an empty string if no match found. And another routine to strip off the project name and return just the path, or if no match then return the Documents directory. This now works nicely on mac OS X and Windows:

(defun string-ends-with (str sub)
  (let 
    ((nstr (length str)) 
    (nsub (length sub)))
    (and (>= nstr nsub) (string-equal sub (subseq str (- nstr nsub) nstr)))))
            
(defun ismacOS()
  (let* ((docsPath (format nil "~a" (get '*system-dir*' documents)))
    (usersPart (subseq docsPath 0 6)))
    (if (string-equal usersPart "/Users") t nil)))

;;;Searches the "Recent Files" menu for an entry that ends with:
;;; (path separator) + (Project Name) + (".aup" or ".aup3")
;;;If it finds such an entry, it returns the fully qualified file name, 
;;;including path, file name, and extension.
;;;If it doesn't find such an entry, it returns the empty string "".
(defun projPathName-from-recents()
  (let ((menus (aud-get-info "menus"))
        (menuStr (if (ismacOS) "Open Recent" "Recent Files"))
        (fnameOld (format nil "~a~a~a" *file-separator* (get '*project*' name) ".aup"))
        (fnameNew (format nil "~a~a~a" *file-separator* (get '*project*' name) ".aup3"))
        isrecent
        recent)
    (dolist (row menus nil)
      (let ((str (second (assoc 'label row))))
        (when isrecent
          (when (string= str "----")
            ;end of recent files section
            (return-from projPathName-from-recents ""))
          ;;Simplified test for match: it must end with 
          ;;(path separator) + (Project Name) + (".aup" or ".aup3")
          (when (or (string-ends-with str fnameNew) (string-ends-with str fnameOld))
            (return-from projPathName-from-recents str)))
        (when (string= str menuStr)
          ;start of recent files section
          (setf isrecent t))))))

;;;Uses projPathName-from-recents to get fully qualified projPathName, then if that 
;;;failed it returns the default documents directory. If it succeeded, it strips off 
;;;the filename and returns only the fully qualified path without path separator.
(defun get-project-folder()
  ;;; Return project's path or if not found
  ;;; return the system default document dir.
  (let* ((projPathName (projPathName-from-recents))
        (nPN (length projPathName))
        (fnameOld (format nil "~a~a~a" *file-separator* (get '*project*' name) ".aup"))
        (nOld (length fnameOld))
        (fnameNew (format nil "~a~a~a" *file-separator* (get '*project*' name) ".aup3"))
        (nNew (length fnameNew))
        (default (get '*system-dir*' documents)))
    (cond 
      ((string= projPathName "")
        (return-from get-project-folder default))
      ((string-ends-with projPathName "3")
        (return-from get-project-folder (subseq projPathName 0 (- nPN nNew))))
      (t
        (return-from get-project-folder (subseq projPathName 0 (- nPN nOld)))))))
(get-project-folder)

I can’t test it on Linux without going to the trouble of setting up a machine.

Thanks so much, your code examples have been enormously helpful.

What does this give you on macOS?

(print *file-separator*)