/********************************************************************** Audacity: A Digital Audio Editor ExportCL.cpp Joshua Haberman This code allows Audacity to export data by piping it to an external program. **********************************************************************/ #include "../ProjectSettings.h" #include #include #include #include #include #include #include #include #if defined(__WXMSW__) #include // for wxRegKey #endif #include "../FileNames.h" #include "Export.h" #include "../Mix.h" #include "../Prefs.h" #include "../ShuttleGui.h" #include "../Tags.h" #include "../Track.h" #include "../float_cast.h" #include "../widgets/FileHistory.h" #include "../widgets/AudacityMessageBox.h" #include "../widgets/ProgressDialog.h" #include "../widgets/Warning.h" #include "../wxFileNameWrapper.h" #ifdef USE_LIBID3TAG #include extern "C" { struct id3_frame *id3_frame_new(char const *); } #endif //---------------------------------------------------------------------------- // ExportCLOptions //---------------------------------------------------------------------------- class ExportCLOptions final : public wxPanelWrapper { public: ExportCLOptions(wxWindow *parent, int format); virtual ~ExportCLOptions(); void PopulateOrExchange(ShuttleGui & S); bool TransferDataToWindow() override; bool TransferDataFromWindow() override; void OnBrowse(wxCommandEvent & event); private: wxComboBox *mCmd; FileHistory mHistory; DECLARE_EVENT_TABLE() }; #define ID_BROWSE 5000 BEGIN_EVENT_TABLE(ExportCLOptions, wxPanelWrapper) EVT_BUTTON(ID_BROWSE, ExportCLOptions::OnBrowse) END_EVENT_TABLE() /// /// ExportCLOptions::ExportCLOptions(wxWindow *parent, int WXUNUSED(format)) : wxPanelWrapper(parent, wxID_ANY) { mHistory.Load(*gPrefs, wxT("/FileFormats/ExternalProgramHistory")); if (mHistory.empty()) { mHistory.Append(wxT("ffmpeg -i - \"%f.opus\"")); mHistory.Append(wxT("ffmpeg -i - \"%f.wav\"")); mHistory.Append(wxT("ffmpeg -i - \"%f\"")); mHistory.Append(wxT("lame - \"%f\"")); } mHistory.Append(gPrefs->Read(wxT("/FileFormats/ExternalProgramExportCommand"), mHistory[ 0 ])); ShuttleGui S(this, eIsCreatingFromPrefs); PopulateOrExchange(S); TransferDataToWindow(); parent->Layout(); } ExportCLOptions::~ExportCLOptions() { TransferDataFromWindow(); } /// /// void ExportCLOptions::PopulateOrExchange(ShuttleGui & S) { wxArrayStringEx cmds( mHistory.begin(), mHistory.end() ); auto cmd = cmds[0]; S.StartVerticalLay(); { S.StartHorizontalLay(wxEXPAND); { S.SetSizerProportion(1); S.StartMultiColumn(3, wxEXPAND); { S.SetStretchyCol(1); mCmd = S.AddCombo(XXO("Command:"), cmd, cmds); S.Id(ID_BROWSE).AddButton(XXO("Browse..."), wxALIGN_CENTER_VERTICAL); S.AddFixedText( {} ); S.TieCheckBox(XXO("Show output"), {wxT("/FileFormats/ExternalProgramShowOutput"), false}); } S.EndMultiColumn(); } S.EndHorizontalLay(); S.AddTitle(XO( /* i18n-hint: Some programmer-oriented terminology here: "Data" refers to the sound to be exported, "piped" means sent, and "standard in" means the default input stream that the external program, named by %f, will read. And yes, it's %f, not %s -- this isn't actually used in the program as a format string. Keep %f unchanged. */ "Data will be piped to standard in. \"%f\" uses the file name in the export window."), 250); } S.EndVerticalLay(); } /// /// bool ExportCLOptions::TransferDataToWindow() { return true; } /// /// bool ExportCLOptions::TransferDataFromWindow() { ShuttleGui S(this, eIsSavingToPrefs); PopulateOrExchange(S); wxString cmd = mCmd->GetValue(); mHistory.Append(cmd); mHistory.Save(*gPrefs); gPrefs->Write(wxT("/FileFormats/ExternalProgramExportCommand"), cmd); gPrefs->Flush(); return true; } /// /// void ExportCLOptions::OnBrowse(wxCommandEvent& WXUNUSED(event)) { wxString path; FileExtension ext; FileNames::FileType type = FileNames::AllFiles; #if defined(__WXMSW__) ext = wxT("exe"); /* i18n-hint files that can be run as programs */ type = { XO("Executables"), { ext } }; #endif path = FileNames::SelectFile(FileNames::Operation::Open, XO("Find path to command"), wxEmptyString, wxEmptyString, ext, { type }, wxFD_OPEN | wxRESIZE_BORDER, this); if (path.empty()) { return; } if (path.Find(wxT(' ')) == wxNOT_FOUND) { mCmd->SetValue(path); } else { mCmd->SetValue(wxT('"') + path + wxT('"')); } mCmd->SetInsertionPointEnd(); return; } //---------------------------------------------------------------------------- // ExportCLProcess //---------------------------------------------------------------------------- static void Drain(wxInputStream *s, wxString *o) { while (s->CanRead()) { char buffer[4096]; s->Read(buffer, WXSIZEOF(buffer) - 1); buffer[s->LastRead()] = wxT('\0'); *o += LAT1CTOWX(buffer); } } class ExportCLProcess final : public wxProcess { public: ExportCLProcess(wxString *output) { #if defined(__WXMAC__) // Don't want to crash on broken pipe signal(SIGPIPE, SIG_IGN); #endif mOutput = output; mActive = true; mStatus = -555; Redirect(); } bool IsActive() { return mActive; } void OnTerminate(int WXUNUSED( pid ), int status) { Drain(GetInputStream(), mOutput); Drain(GetErrorStream(), mOutput); mStatus = status; mActive = false; } int GetStatus() { return mStatus; } private: wxString *mOutput; bool mActive; int mStatus; }; //---------------------------------------------------------------------------- // ExportCL //---------------------------------------------------------------------------- class ExportCL final : public ExportPlugin { public: ExportCL(); // Required void OptionsCreate(ShuttleGui &S, int format) override; ProgressResult Export(AudacityProject *project, std::unique_ptr &pDialog, unsigned channels, const wxFileNameWrapper &fName, bool selectedOnly, double t0, double t1, MixerSpec *mixerSpec = NULL, const Tags *metadata = NULL, int subformat = 0) override; // Optional bool CheckFileName(wxFileName &filename, int format = 0) override; private: void GetSettings(); std::vector GetMetaChunk(const Tags *metadata); wxString mCmd; bool mShow; struct ExtendPath { #if defined(__WXMSW__) wxString opath; ExtendPath() { // Give Windows a chance at finding lame command in the default location. wxString paths[] = {wxT("HKEY_LOCAL_MACHINE\\Software\\Lame for Audacity"), wxT("HKEY_LOCAL_MACHINE\\Software\\FFmpeg for Audacity")}; wxString npath; wxRegKey reg; wxGetEnv(wxT("PATH"), &opath); npath = opath; for (int i = 0; i < WXSIZEOF(paths); i++) { reg.SetName(paths[i]); if (reg.Exists()) { wxString ipath; reg.QueryValue(wxT("InstallPath"), ipath); if (!ipath.empty()) { npath += wxPATH_SEP + ipath; } } } wxSetEnv(wxT("PATH"),npath); }; ~ExtendPath() { if (!opath.empty()) { wxSetEnv(wxT("PATH"),opath); } } #endif }; }; ExportCL::ExportCL() : ExportPlugin() { AddFormat(); SetFormat(wxT("CL"),0); AddExtension(wxT(""),0); SetMaxChannels(255,0); SetCanMetaData(false,0); SetDescription(XO("(external program)"),0); } ProgressResult ExportCL::Export(AudacityProject *project, std::unique_ptr &pDialog, unsigned channels, const wxFileNameWrapper &fName, bool selectionOnly, double t0, double t1, MixerSpec *mixerSpec, const Tags *metadata, int WXUNUSED(subformat)) { ExtendPath ep; wxString output; long rc; const auto path = fName.GetFullPath(); GetSettings(); // Bug 2178 - users who don't know what they are doing will // now get a file extension of .wav appended to their ffmpeg filename // and therefore ffmpeg will be able to choose a file type. if( mCmd == wxT("ffmpeg -i - \"%f\"") && !fName.HasExt()) mCmd.Replace( "%f", "%f.wav" ); mCmd.Replace(wxT("%f"), path); // Kick off the command ExportCLProcess process(&output); rc = wxExecute(mCmd, wxEXEC_ASYNC, &process); if (!rc) { AudacityMessageBox( XO("Cannot export audio to %s").Format( path ) ); process.Detach(); process.CloseOutput(); return ProgressResult::Cancelled; } // Turn off logging to prevent broken pipe messages wxLogNull nolog; // establish parameters int rate = lrint( ProjectSettings::Get( *project ).GetRate()); const size_t maxBlockLen = 44100 * 5; unsigned long totalSamples = lrint((t1 - t0) * rate); unsigned long sampleBytes = totalSamples * channels * SAMPLE_SIZE(floatSample); wxOutputStream *os = process.GetOutputStream(); // RIFF header struct { char riffID[4]; // "RIFF" wxUint32 riffLen; // basically the file len - 8 char riffType[4]; // "WAVE" } riff; // format chunk */ struct { char fmtID[4]; // "fmt " */ wxUint32 formatChunkLen; // (format chunk len - first two fields) 16 in our case wxUint16 formatTag; // 1 for PCM wxUint16 channels; wxUint32 sampleRate; wxUint32 avgBytesPerSec; // sampleRate * blockAlign wxUint16 blockAlign; // bitsPerSample * channels (assume bps % 8 = 0) wxUint16 bitsPerSample; } fmt; // id3 chunk header struct { char id3ID[4]; // "id3 " wxUint32 id3Len; // length of metadata in bytes } id3; // data chunk header struct { char dataID[4]; // "data" wxUint32 dataLen; // length of all samples in bytes } data; riff.riffID[0] = 'R'; riff.riffID[1] = 'I'; riff.riffID[2] = 'F'; riff.riffID[3] = 'F'; riff.riffLen = wxUINT32_SWAP_ON_BE(sizeof(riff) + sizeof(fmt) + sizeof(data) + sampleBytes - 8); riff.riffType[0] = 'W'; riff.riffType[1] = 'A'; riff.riffType[2] = 'V'; riff.riffType[3] = 'E'; fmt.fmtID[0] = 'f'; fmt.fmtID[1] = 'm'; fmt.fmtID[2] = 't'; fmt.fmtID[3] = ' '; fmt.formatChunkLen = wxUINT32_SWAP_ON_BE(16); fmt.formatTag = wxUINT16_SWAP_ON_BE(3); fmt.channels = wxUINT16_SWAP_ON_BE(channels); fmt.sampleRate = wxUINT32_SWAP_ON_BE(rate); fmt.bitsPerSample = wxUINT16_SWAP_ON_BE(SAMPLE_SIZE(floatSample) * 8); fmt.blockAlign = wxUINT16_SWAP_ON_BE(fmt.bitsPerSample * fmt.channels / 8); fmt.avgBytesPerSec = wxUINT32_SWAP_ON_BE(fmt.sampleRate * fmt.blockAlign); // Retrieve tags if not given a set if (metadata == NULL) { metadata = &Tags::Get(*project); } auto metachunk = GetMetaChunk(metadata); if (metachunk.size()) { id3.id3ID[0] = 'i'; id3.id3ID[1] = 'd'; id3.id3ID[2] = '3'; id3.id3ID[3] = ' '; id3.id3Len = wxUINT32_SWAP_ON_BE(metachunk.size()); riff.riffLen += sizeof(id3) + metachunk.size(); } data.dataID[0] = 'd'; data.dataID[1] = 'a'; data.dataID[2] = 't'; data.dataID[3] = 'a'; data.dataLen = wxUINT32_SWAP_ON_BE(sampleBytes); // write the headers and metadata os->Write(&riff, sizeof(riff)); os->Write(&fmt, sizeof(fmt)); if (metachunk.size()) { os->Write(&id3, sizeof(id3)); os->Write(metachunk.data(), metachunk.size()); } os->Write(&data, sizeof(data)); // Mix 'em up const auto &tracks = TrackList::Get( *project ); auto mixer = CreateMixer( tracks, selectionOnly, t0, t1, channels, maxBlockLen, true, rate, floatSample, mixerSpec); size_t numBytes = 0; samplePtr mixed = NULL; auto updateResult = ProgressResult::Success; { auto closeIt = finally ( [&] { // Should make the process die, before propagating any exception process.CloseOutput(); } ); // Prepare the progress display InitProgress( pDialog, XO("Export"), selectionOnly ? XO("Exporting the selected audio using command-line encoder") : XO("Exporting the audio using command-line encoder") ); auto &progress = *pDialog; // Start piping the mixed data to the command while (updateResult == ProgressResult::Success && process.IsActive() && os->IsOk()) { // Capture any stdout and stderr from the command Drain(process.GetInputStream(), &output); Drain(process.GetErrorStream(), &output); // Need to mix another block if (numBytes == 0) { auto numSamples = mixer->Process(maxBlockLen); if (numSamples == 0) { break; } mixed = mixer->GetBuffer(); numBytes = numSamples * channels; // Byte-swapping is necessary on big-endian machines, since // WAV files are little-endian #if wxBYTE_ORDER == wxBIG_ENDIAN float *buffer = (float *) mixed; for (int i = 0; i < numBytes; i++) { buffer[i] = wxUINT32_SWAP_ON_BE(buffer[i]); } #endif numBytes *= SAMPLE_SIZE(floatSample); } // Don't write too much at once...pipes may not be able to handle it size_t bytes = wxMin(numBytes, 4096); numBytes -= bytes; while (bytes > 0) { os->Write(mixed, bytes); if (!os->IsOk()) { updateResult = ProgressResult::Cancelled; break; } bytes -= os->LastWrite(); mixed += os->LastWrite(); } // Update the progress display updateResult = progress.Update(mixer->MixGetCurrentTime() - t0, t1 - t0); } // Done with the progress display } // Wait for process to terminate while (process.IsActive()) { wxMilliSleep(10); wxTheApp->Yield(); } // Display output on error or if the user wants to see it if (process.GetStatus() != 0 || mShow) { // TODO use ShowInfoDialog() instead. wxDialogWrapper dlg(nullptr, wxID_ANY, XO("Command Output"), wxDefaultPosition, wxSize(600, 400), wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER); dlg.SetName(); ShuttleGui S(&dlg, eIsCreating); S .Style( wxTE_MULTILINE | wxTE_READONLY | wxTE_RICH ) .AddTextWindow(mCmd + wxT("\n\n") + output); S.StartHorizontalLay(wxALIGN_CENTER, false); { S.Id(wxID_OK).AddButton(XXO("&OK"), wxALIGN_CENTER, true); } dlg.GetSizer()->AddSpacer(5); dlg.Layout(); dlg.SetMinSize(dlg.GetSize()); dlg.Center(); dlg.ShowModal(); if (process.GetStatus() != 0) updateResult = ProgressResult::Failed; } return updateResult; } std::vector ExportCL::GetMetaChunk(const Tags *tags) { std::vector buffer; #ifdef USE_LIBID3TAG struct id3_tag_deleter { void operator () (id3_tag *p) const { if (p) id3_tag_delete(p); } }; std::unique_ptr tp { id3_tag_new() }; for (const auto &pair : tags->GetRange()) { const auto &n = pair.first; const auto &v = pair.second; const char *name = "TXXX"; if (n.CmpNoCase(TAG_TITLE) == 0) { name = ID3_FRAME_TITLE; } else if (n.CmpNoCase(TAG_ARTIST) == 0) { name = ID3_FRAME_ARTIST; } else if (n.CmpNoCase(TAG_ALBUM) == 0) { name = ID3_FRAME_ALBUM; } else if (n.CmpNoCase(TAG_YEAR) == 0) { name = ID3_FRAME_YEAR; } else if (n.CmpNoCase(TAG_GENRE) == 0) { name = ID3_FRAME_GENRE; } else if (n.CmpNoCase(TAG_COMMENTS) == 0) { name = ID3_FRAME_COMMENT; } else if (n.CmpNoCase(TAG_TRACK) == 0) { name = ID3_FRAME_TRACK; } else if (n.CmpNoCase(wxT("composer")) == 0) { name = "TCOM"; } struct id3_frame *frame = id3_frame_new(name); if (!n.IsAscii() || !v.IsAscii()) { id3_field_settextencoding(id3_frame_field(frame, 0), ID3_FIELD_TEXTENCODING_UTF_16); } else { id3_field_settextencoding(id3_frame_field(frame, 0), ID3_FIELD_TEXTENCODING_ISO_8859_1); } MallocString ucs4{ id3_utf8_ucs4duplicate((id3_utf8_t *) (const char *) v.mb_str(wxConvUTF8)) }; if (strcmp(name, ID3_FRAME_COMMENT) == 0) { // A hack to get around iTunes not recognizing the comment. The // language defaults to XXX and, since it's not a valid language, // iTunes just ignores the tag. So, either set it to a valid language // (which one???) or just clear it. Unfortunately, there's no supported // way of clearing the field, so do it directly. id3_field *f = id3_frame_field(frame, 1); memset(f->immediate.value, 0, sizeof(f->immediate.value)); id3_field_setfullstring(id3_frame_field(frame, 3), ucs4.get()); } else if (strcmp(name, "TXXX") == 0) { id3_field_setstring(id3_frame_field(frame, 2), ucs4.get()); ucs4.reset(id3_utf8_ucs4duplicate((id3_utf8_t *) (const char *) n.mb_str(wxConvUTF8))); id3_field_setstring(id3_frame_field(frame, 1), ucs4.get()); } else { auto addr = ucs4.get(); id3_field_setstrings(id3_frame_field(frame, 1), 1, &addr); } id3_tag_attachframe(tp.get(), frame); } tp->options &= (~ID3_TAG_OPTION_COMPRESSION); // No compression // If this version of libid3tag supports it, use v2.3 ID3 // tags instead of the newer, but less well supported, v2.4 // that libid3tag uses by default. #ifdef ID3_TAG_HAS_TAG_OPTION_ID3V2_3 tp->options |= ID3_TAG_OPTION_ID3V2_3; #endif id3_length_t len; len = id3_tag_render(tp.get(), 0); if ((len % 2) != 0) { len++; // Length must be even. } if (len > 0) { buffer.resize(len); id3_tag_render(tp.get(), (id3_byte_t *) buffer.data()); } #endif return buffer; } void ExportCL::OptionsCreate(ShuttleGui &S, int format) { S.AddWindow( safenew ExportCLOptions{ S.GetParent(), format } ); } bool ExportCL::CheckFileName(wxFileName &filename, int WXUNUSED(format)) { ExtendPath ep; if (filename.GetExt().empty()) { if (ShowWarningDialog(NULL, wxT("MissingExtension"), XO("You've specified a file name without an extension. Are you sure?"), true) == wxID_CANCEL) { return false; } } GetSettings(); wxArrayString argv = wxCmdLineParser::ConvertStringToArgs(mCmd, #if defined(__WXMSW__) wxCMD_LINE_SPLIT_DOS #else wxCMD_LINE_SPLIT_UNIX #endif ); if (argv.size() == 0) { ShowExportErrorDialog( ":745", XO("Program name appears to be missing.")); return false; } // Normalize the path (makes absolute and resolves variables) wxFileName cmd(argv[0]); cmd.Normalize(wxPATH_NORM_ALL & ~wxPATH_NORM_ABSOLUTE); // Just verify the given path exists if it is absolute. if (cmd.IsAbsolute()) { if (!cmd.Exists()) { AudacityMessageBox( XO("\"%s\" couldn't be found.").Format(cmd.GetFullPath()), XO("Warning"), wxOK | wxICON_EXCLAMATION); return false; } return true; } // Search for the command in the PATH list wxPathList pathlist; pathlist.AddEnvList(wxT("PATH")); wxString path = pathlist.FindAbsoluteValidPath(argv[0]); #if defined(__WXMSW__) if (path.empty()) { path = pathlist.FindAbsoluteValidPath(argv[0] + wxT(".exe")); } #endif if (path.empty()) { int action = AudacityMessageBox( XO("Unable to locate \"%s\" in your path.").Format(cmd.GetFullPath()), XO("Warning"), wxOK | wxICON_EXCLAMATION); return false; } return true; } void ExportCL::GetSettings() { // Retrieve settings gPrefs->Read(wxT("/FileFormats/ExternalProgramShowOutput"), &mShow, false); mCmd = gPrefs->Read(wxT("/FileFormats/ExternalProgramExportCommand"), wxT("lame - \"%f.mp3\"")); } static Exporter::RegisteredExportPlugin sRegisteredPlugin{ "CommandLine", []{ return std::make_unique< ExportCL >(); } };