From 1b6f33c772edf4e69265dbf0fda7cc0c863a188e Mon Sep 17 00:00:00 2001 From: Siyabonga Buthelezi <114085572+ElliotBadinger@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:16:39 +0200 Subject: [PATCH 1/2] Agent Session 2025-10-06: finalize Kotlin UI and audio ports --- SaidIt/build.gradle.kts | 2 +- SaidIt/libs/jcaki-1.0-Alpha.jar | Bin 54993 -> 0 bytes .../eu/mrogalski/saidit/HowToActivity.java | 30 -- .../mrogalski/saidit/HowToPageFragment.java | 52 --- .../mrogalski/saidit/HowToPagerAdapter.java | 30 -- .../mrogalski/saidit/RecordingsAdapter.java | 258 -------------- .../main/java/eu/mrogalski/saidit/SaidIt.java | 17 - .../main/java/simplesound/dsp/Complex.java | 12 - .../java/simplesound/dsp/DoubleVector.java | 32 -- .../dsp/DoubleVectorFrameSource.java | 74 ---- .../dsp/DoubleVectorProcessingPipeline.java | 16 - .../dsp/DoubleVectorProcessor.java | 8 - .../java/simplesound/dsp/MutableComplex.java | 20 -- .../dsp/NormalizedFrameIterator.java | 77 ---- .../java/simplesound/dsp/WindowerFactory.java | 45 --- .../simplesound/pcm/MonoWavFileReader.java | 76 ---- .../java/simplesound/pcm/PcmAudioFormat.java | 139 -------- .../java/simplesound/pcm/PcmAudioHelper.java | 68 ---- .../simplesound/pcm/PcmMonoInputStream.java | 163 --------- .../simplesound/pcm/PcmMonoOutputStream.java | 43 --- .../java/simplesound/pcm/RiffHeaderData.java | 129 ------- .../java/simplesound/pcm/WavAudioFormat.java | 69 ---- .../java/simplesound/pcm/WavFileWriter.java | 86 ----- .../eu/mrogalski/saidit/HowToActivity.kt | 31 ++ .../eu/mrogalski/saidit/HowToPageFragment.kt | 28 ++ .../eu/mrogalski/saidit/HowToPagerAdapter.kt | 12 + .../kotlin/eu/mrogalski/saidit/HowToStep.kt | 22 ++ .../eu/mrogalski/saidit/RecordingsAdapter.kt | 336 ++++++++++++++++++ .../main/kotlin/eu/mrogalski/saidit/SaidIt.kt | 15 + .../src/main/res/layout/activity_how_to.xml | 2 +- .../main/res/layout/fragment_how_to_page.xml | 14 +- SaidIt/src/main/res/values/strings.xml | 7 + .../eu/mrogalski/saidit/HowToActivityTest.kt | 52 +++ .../mrogalski/saidit/RecordingsAdapterTest.kt | 181 ++++++++++ audio/build.gradle.kts | 24 +- .../siya/epistemophile/audio/dsp/Complex.kt | 3 + .../epistemophile/audio/dsp/DoubleVector.kt | 12 + .../audio/dsp/DoubleVectorFrameSource.kt | 53 +++ .../dsp/DoubleVectorProcessingPipeline.kt | 17 + .../audio/dsp/DoubleVectorProcessor.kt | 7 + .../epistemophile/audio/dsp/MutableComplex.kt | 8 + .../audio/dsp/NormalizedFrameIterator.kt | 70 ++++ .../audio/dsp/WindowerFactory.kt | 40 +++ .../audio/pcm/MonoWavFileReader.kt | 48 +++ .../epistemophile/audio/pcm/PcmAudioFormat.kt | 54 +++ .../epistemophile/audio/pcm/PcmAudioHelper.kt | 59 +++ .../epistemophile/audio/pcm/PcmByteUtils.kt | 136 +++++++ .../audio/pcm/PcmMonoInputStream.kt | 115 ++++++ .../audio/pcm/PcmMonoOutputStream.kt | 44 +++ .../epistemophile/audio/pcm/RiffHeaderData.kt | 112 ++++++ .../epistemophile/audio/pcm/WavAudioFormat.kt | 35 ++ .../epistemophile/audio/pcm/WavFileWriter.kt | 70 ++++ .../audio/AudioPlayerRecorderTest.kt | 57 ++- .../audio/dsp/NormalizedFrameIteratorTest.kt | 54 +++ .../audio/dsp/WindowerFactoryTest.kt | 47 +++ .../siya/epistemophile/audio/pcm/WavIoTest.kt | 67 ++++ docs/architecture/kotlin-migration-plan.md | 31 +- 57 files changed, 1800 insertions(+), 1509 deletions(-) delete mode 100644 SaidIt/libs/jcaki-1.0-Alpha.jar delete mode 100644 SaidIt/src/main/java/eu/mrogalski/saidit/HowToActivity.java delete mode 100644 SaidIt/src/main/java/eu/mrogalski/saidit/HowToPageFragment.java delete mode 100644 SaidIt/src/main/java/eu/mrogalski/saidit/HowToPagerAdapter.java delete mode 100644 SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsAdapter.java delete mode 100644 SaidIt/src/main/java/eu/mrogalski/saidit/SaidIt.java delete mode 100644 SaidIt/src/main/java/simplesound/dsp/Complex.java delete mode 100644 SaidIt/src/main/java/simplesound/dsp/DoubleVector.java delete mode 100644 SaidIt/src/main/java/simplesound/dsp/DoubleVectorFrameSource.java delete mode 100644 SaidIt/src/main/java/simplesound/dsp/DoubleVectorProcessingPipeline.java delete mode 100644 SaidIt/src/main/java/simplesound/dsp/DoubleVectorProcessor.java delete mode 100644 SaidIt/src/main/java/simplesound/dsp/MutableComplex.java delete mode 100644 SaidIt/src/main/java/simplesound/dsp/NormalizedFrameIterator.java delete mode 100644 SaidIt/src/main/java/simplesound/dsp/WindowerFactory.java delete mode 100644 SaidIt/src/main/java/simplesound/pcm/MonoWavFileReader.java delete mode 100644 SaidIt/src/main/java/simplesound/pcm/PcmAudioFormat.java delete mode 100644 SaidIt/src/main/java/simplesound/pcm/PcmAudioHelper.java delete mode 100644 SaidIt/src/main/java/simplesound/pcm/PcmMonoInputStream.java delete mode 100644 SaidIt/src/main/java/simplesound/pcm/PcmMonoOutputStream.java delete mode 100644 SaidIt/src/main/java/simplesound/pcm/RiffHeaderData.java delete mode 100644 SaidIt/src/main/java/simplesound/pcm/WavAudioFormat.java delete mode 100644 SaidIt/src/main/java/simplesound/pcm/WavFileWriter.java create mode 100644 SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToActivity.kt create mode 100644 SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToPageFragment.kt create mode 100644 SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToPagerAdapter.kt create mode 100644 SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToStep.kt create mode 100644 SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt create mode 100644 SaidIt/src/main/kotlin/eu/mrogalski/saidit/SaidIt.kt create mode 100644 SaidIt/src/test/kotlin/eu/mrogalski/saidit/HowToActivityTest.kt create mode 100644 SaidIt/src/test/kotlin/eu/mrogalski/saidit/RecordingsAdapterTest.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/Complex.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVector.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorFrameSource.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorProcessingPipeline.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorProcessor.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/MutableComplex.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/NormalizedFrameIterator.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/WindowerFactory.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/MonoWavFileReader.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmAudioFormat.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmAudioHelper.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmByteUtils.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmMonoInputStream.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmMonoOutputStream.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/RiffHeaderData.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/WavAudioFormat.kt create mode 100644 audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/WavFileWriter.kt create mode 100644 audio/src/test/kotlin/com/siya/epistemophile/audio/dsp/NormalizedFrameIteratorTest.kt create mode 100644 audio/src/test/kotlin/com/siya/epistemophile/audio/dsp/WindowerFactoryTest.kt create mode 100644 audio/src/test/kotlin/com/siya/epistemophile/audio/pcm/WavIoTest.kt diff --git a/SaidIt/build.gradle.kts b/SaidIt/build.gradle.kts index 10472b5e..52c7ba4f 100644 --- a/SaidIt/build.gradle.kts +++ b/SaidIt/build.gradle.kts @@ -61,13 +61,13 @@ kapt { } dependencies { - implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) implementation(libs.androidx.appcompat) implementation(libs.material) implementation(libs.tap.target.view) implementation(project(":core")) implementation(project(":domain")) implementation(project(":data")) + implementation(project(":audio")) implementation(libs.hilt.android) kapt(libs.hilt.compiler) annotationProcessor(libs.hilt.compiler) diff --git a/SaidIt/libs/jcaki-1.0-Alpha.jar b/SaidIt/libs/jcaki-1.0-Alpha.jar deleted file mode 100644 index 0857e11f1606200e8cecfeea1df640a99ada4130..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54993 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 diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToActivity.java b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToActivity.java deleted file mode 100644 index ac43b8a6..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToActivity.java +++ /dev/null @@ -1,30 +0,0 @@ -package eu.mrogalski.saidit; - -import android.os.Bundle; -import androidx.appcompat.app.AppCompatActivity; -import com.siya.epistemophile.R; -import androidx.viewpager2.widget.ViewPager2; -import com.google.android.material.appbar.MaterialToolbar; -import com.google.android.material.tabs.TabLayout; -import com.google.android.material.tabs.TabLayoutMediator; - -public class HowToActivity extends AppCompatActivity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_how_to); - - MaterialToolbar toolbar = findViewById(R.id.toolbar); - toolbar.setNavigationOnClickListener(v -> finish()); - - ViewPager2 viewPager = findViewById(R.id.view_pager); - TabLayout tabLayout = findViewById(R.id.tab_layout); - - viewPager.setAdapter(new HowToPagerAdapter(this)); - - new TabLayoutMediator(tabLayout, viewPager, - (tab, position) -> tab.setText("Step " + (position + 1)) - ).attach(); - } -} diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPageFragment.java b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPageFragment.java deleted file mode 100644 index 69cd4b8c..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPageFragment.java +++ /dev/null @@ -1,52 +0,0 @@ -package eu.mrogalski.saidit; - -import com.siya.epistemophile.R; - - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -public class HowToPageFragment extends Fragment { - - private static final String ARG_POSITION = "position"; - - public static HowToPageFragment newInstance(int position) { - HowToPageFragment fragment = new HowToPageFragment(); - Bundle args = new Bundle(); - args.putInt(ARG_POSITION, position); - fragment.setArguments(args); - return fragment; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_how_to_page, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - TextView textView = view.findViewById(R.id.how_to_text); - int position = getArguments().getInt(ARG_POSITION); - // Set text based on position - switch (position) { - case 0: - textView.setText("Step 1: Press the record button to start saving audio."); - break; - case 1: - textView.setText("Step 2: Press the save button to save the last few minutes of audio."); - break; - case 2: - textView.setText("Step 3: Access your saved recordings from the recordings manager."); - break; - } - } -} - diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPagerAdapter.java b/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPagerAdapter.java deleted file mode 100644 index c5e7202a..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/HowToPagerAdapter.java +++ /dev/null @@ -1,30 +0,0 @@ -package eu.mrogalski.saidit; - -import com.siya.epistemophile.R; - - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -public class HowToPagerAdapter extends FragmentStateAdapter { - - private static final int NUM_PAGES = 3; // Example number of pages - - public HowToPagerAdapter(@NonNull FragmentActivity fragmentActivity) { - super(fragmentActivity); - } - - @NonNull - @Override - public Fragment createFragment(int position) { - return HowToPageFragment.newInstance(position); - } - - @Override - public int getItemCount() { - return NUM_PAGES; - } -} - diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsAdapter.java b/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsAdapter.java deleted file mode 100644 index fd63c3ac..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/RecordingsAdapter.java +++ /dev/null @@ -1,258 +0,0 @@ -package eu.mrogalski.saidit; - -import com.siya.epistemophile.R; - - -import android.content.ContentResolver; -import android.content.Context; -import android.media.MediaPlayer; -import android.net.Uri; -import android.provider.MediaStore; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.concurrent.TimeUnit; - -public class RecordingsAdapter extends RecyclerView.Adapter { - - private static final int TYPE_HEADER = 0; - private static final int TYPE_ITEM = 1; - - private final List items; - private final Context context; - private MediaPlayer mediaPlayer; - private int playingPosition = -1; - - public RecordingsAdapter(Context context, List recordings) { - this.context = context; - this.items = groupRecordingsByDate(recordings); - } - - private List groupRecordingsByDate(List recordings) { - List groupedList = new ArrayList<>(); - if (recordings.isEmpty()) { - return groupedList; - } - - String lastHeader = ""; - for (RecordingItem recording : recordings) { - String header = getDayHeader(recording.getDate()); - if (!header.equals(lastHeader)) { - groupedList.add(header); - lastHeader = header; - } - groupedList.add(recording); - } - return groupedList; - } - - private String getDayHeader(long timestamp) { - Calendar now = Calendar.getInstance(); - Calendar timeToCheck = Calendar.getInstance(); - timeToCheck.setTimeInMillis(timestamp * 1000); - - if (now.get(Calendar.YEAR) == timeToCheck.get(Calendar.YEAR) && now.get(Calendar.DAY_OF_YEAR) == timeToCheck.get(Calendar.DAY_OF_YEAR)) { - return "Today"; - } else { - now.add(Calendar.DAY_OF_YEAR, -1); - if (now.get(Calendar.YEAR) == timeToCheck.get(Calendar.YEAR) && now.get(Calendar.DAY_OF_YEAR) == timeToCheck.get(Calendar.DAY_OF_YEAR)) { - return "Yesterday"; - } else { - return new SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()).format(timeToCheck.getTime()); - } - } - } - - public void releasePlayer() { - if (mediaPlayer != null) { - mediaPlayer.release(); - mediaPlayer = null; - playingPosition = -1; - notifyDataSetChanged(); - } - } - - @Override - public int getItemViewType(int position) { - if (items.get(position) instanceof String) { - return TYPE_HEADER; - } - return TYPE_ITEM; - } - - @NonNull - @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - if (viewType == TYPE_HEADER) { - View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_header, parent, false); - return new HeaderViewHolder(view); - } - View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_recording, parent, false); - return new RecordingViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { - if (holder.getItemViewType() == TYPE_HEADER) { - HeaderViewHolder headerHolder = (HeaderViewHolder) holder; - headerHolder.bind((String) items.get(position)); - } else { - RecordingViewHolder itemHolder = (RecordingViewHolder) holder; - RecordingItem recording = (RecordingItem) items.get(position); - itemHolder.bind(recording); - - if (position == playingPosition) { - itemHolder.playButton.setIconResource(R.drawable.ic_pause); - } else { - itemHolder.playButton.setIconResource(R.drawable.ic_play_arrow); - } - } - } - - @Override - public int getItemCount() { - return items.size(); - } - - class RecordingViewHolder extends RecyclerView.ViewHolder { - private final TextView nameTextView; - private final TextView infoTextView; - private final MaterialButton playButton; - private final MaterialButton deleteButton; - - public RecordingViewHolder(@NonNull View itemView) { - super(itemView); - nameTextView = itemView.findViewById(R.id.recording_name_text); - infoTextView = itemView.findViewById(R.id.recording_info_text); - playButton = itemView.findViewById(R.id.play_button); - deleteButton = itemView.findViewById(R.id.delete_button); - } - - public void bind(RecordingItem recording) { - nameTextView.setText(recording.getName()); - - Date date = new Date(recording.getDate() * 1000); // MediaStore date is in seconds - SimpleDateFormat formatter = new SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()); - String dateString = formatter.format(date); - - long durationMillis = recording.getDuration(); - String durationString = String.format(Locale.getDefault(), "%02d:%02d", - TimeUnit.MILLISECONDS.toMinutes(durationMillis), - TimeUnit.MILLISECONDS.toSeconds(durationMillis) - - TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(durationMillis)) - ); - - infoTextView.setText(String.format("%s | %s", durationString, dateString)); - - playButton.setOnClickListener(v -> handlePlayback(recording, getAdapterPosition())); - - deleteButton.setOnClickListener(v -> { - new MaterialAlertDialogBuilder(context) - .setTitle("Delete Recording") - .setMessage("Are you sure you want to permanently delete this file?") - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton("Delete", (dialog, which) -> { - int currentPosition = getAdapterPosition(); - if (currentPosition != RecyclerView.NO_POSITION) { - // Stop playback if the deleted item is the one playing - if (playingPosition == currentPosition) { - releasePlayer(); - } - - RecordingItem itemToDelete = (RecordingItem) items.get(currentPosition); - ContentResolver contentResolver = context.getContentResolver(); - int deletedRows = contentResolver.delete(itemToDelete.getUri(), null, null); - - if (deletedRows > 0) { - items.remove(currentPosition); - notifyItemRemoved(currentPosition); - notifyItemRangeChanged(currentPosition, items.size()); - // Adjust playing position if an item before it was removed - if (playingPosition > currentPosition) { - playingPosition--; - } - - // Check if the header is now orphaned - if (currentPosition > 0 && items.get(currentPosition - 1) instanceof String) { - if (currentPosition == items.size() || items.get(currentPosition) instanceof String) { - items.remove(currentPosition - 1); - notifyItemRemoved(currentPosition - 1); - notifyItemRangeChanged(currentPosition - 1, items.size()); - if (playingPosition >= currentPosition) { - playingPosition--; - } - } - } - } - } - }) - .show(); - }); - } - - private void handlePlayback(RecordingItem recording, int position) { - if (playingPosition == position) { - if (mediaPlayer.isPlaying()) { - mediaPlayer.pause(); - playButton.setIconResource(R.drawable.ic_play_arrow); - } else { - mediaPlayer.start(); - playButton.setIconResource(R.drawable.ic_pause); - } - } else { - if (mediaPlayer != null) { - mediaPlayer.release(); - notifyItemChanged(playingPosition); - } - - int previousPlayingPosition = playingPosition; - playingPosition = position; - - if (previousPlayingPosition != -1) { - notifyItemChanged(previousPlayingPosition); - } - - mediaPlayer = new MediaPlayer(); - try { - mediaPlayer.setDataSource(context, recording.getUri()); - mediaPlayer.prepare(); - mediaPlayer.setOnCompletionListener(mp -> { - playingPosition = -1; - notifyItemChanged(position); - }); - mediaPlayer.start(); - playButton.setIconResource(R.drawable.ic_pause); - } catch (IOException e) { - e.printStackTrace(); - playingPosition = -1; - } - } - } - } - - class HeaderViewHolder extends RecyclerView.ViewHolder { - private final TextView headerTextView; - - public HeaderViewHolder(@NonNull View itemView) { - super(itemView); - headerTextView = itemView.findViewById(R.id.header_text_view); - } - - public void bind(String text) { - headerTextView.setText(text); - } - } -} - diff --git a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidIt.java b/SaidIt/src/main/java/eu/mrogalski/saidit/SaidIt.java deleted file mode 100644 index c06add4b..00000000 --- a/SaidIt/src/main/java/eu/mrogalski/saidit/SaidIt.java +++ /dev/null @@ -1,17 +0,0 @@ -package eu.mrogalski.saidit; - -public class SaidIt { - - static final String PACKAGE_NAME = "eu.mrogalski.saidit"; - static final String AUDIO_MEMORY_ENABLED_KEY = "audio_memory_enabled"; - static final String AUDIO_MEMORY_SIZE_KEY = "audio_memory_size"; - static final String SAMPLE_RATE_KEY = "sample_rate"; - static final String SKU = "unlimited_history"; - static final String BASE64_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlD0FMFGp4AWzjW" + - "LTsUZgm0soga0mVVNGFj0qoATaoQCE/LamF7yrMCIFm9sEOB1guCEhzdr16sjysrVc2EPRisS83FoJ4K0R8" + - "XPDP2TrVT2SAeQpTCG27NNH+W86SlGEqQeQhMPMhR+HDTckHv3KBpD8BZEEIbkXPv6SGFqcZub6xzn9r14l" + - "6ptYIWboKGGBh1i9/nJpdhCMPxuLn/WZnRXGxqGpfNw2xT25/muUDZgRVezy6/5eI+ciMn5H1U0ADBjXvl1" + - "Py+4ClkR1V1Mfo9lvauB03zM8Fsa3LlIPle5a+wGKsRCLW/rJ/eE/rje6X7x/n+w8J4OiFvVATj0T8QIDAQ" + - "AB"; - -} diff --git a/SaidIt/src/main/java/simplesound/dsp/Complex.java b/SaidIt/src/main/java/simplesound/dsp/Complex.java deleted file mode 100644 index 27b83597..00000000 --- a/SaidIt/src/main/java/simplesound/dsp/Complex.java +++ /dev/null @@ -1,12 +0,0 @@ -package simplesound.dsp; - -public final class Complex { - - public final double real; - public final double imaginary; - - public Complex(double real, double imaginary) { - this.real = real; - this.imaginary = imaginary; - } -} diff --git a/SaidIt/src/main/java/simplesound/dsp/DoubleVector.java b/SaidIt/src/main/java/simplesound/dsp/DoubleVector.java deleted file mode 100644 index 112a7e57..00000000 --- a/SaidIt/src/main/java/simplesound/dsp/DoubleVector.java +++ /dev/null @@ -1,32 +0,0 @@ -package simplesound.dsp; - -import java.util.Arrays; - -/** - * a vector containing a double numbers. - */ -public class DoubleVector { - - final double[] data; - - public DoubleVector(double[] data) { - if (data == null) - throw new IllegalArgumentException("Data cannot be null!"); - this.data = data; - } - - public int size() { - return data.length; - } - - public double[] getData() { - return data; - } - - - @Override - public String toString() { - return Arrays.toString(data); - } - -} diff --git a/SaidIt/src/main/java/simplesound/dsp/DoubleVectorFrameSource.java b/SaidIt/src/main/java/simplesound/dsp/DoubleVectorFrameSource.java deleted file mode 100644 index d4fc83f7..00000000 --- a/SaidIt/src/main/java/simplesound/dsp/DoubleVectorFrameSource.java +++ /dev/null @@ -1,74 +0,0 @@ -package simplesound.dsp; - -import simplesound.pcm.PcmMonoInputStream; - -import java.util.Iterator; - -public class DoubleVectorFrameSource { - - private final PcmMonoInputStream pmis; - private final int frameSize; - private final int shiftAmount; - private final boolean paddingApplied; - - private DoubleVectorFrameSource(PcmMonoInputStream pmis, int frameSize, int shiftAmount, boolean paddingApplied) { - this.pmis = pmis; - this.frameSize = frameSize; - this.shiftAmount = shiftAmount; - this.paddingApplied = paddingApplied; - } - - public static DoubleVectorFrameSource fromSampleAmount( - PcmMonoInputStream pmis, int frameSize, int shiftAmount) { - return new DoubleVectorFrameSource(pmis, frameSize, shiftAmount, false); - } - - public static DoubleVectorFrameSource fromSampleAmountWithPadding( - PcmMonoInputStream pmis, int frameSize, int shiftAmount) { - return new DoubleVectorFrameSource(pmis, frameSize, shiftAmount, true); - } - - public static DoubleVectorFrameSource fromSizeInMiliseconds( - PcmMonoInputStream pmis, double frameSizeInMilis, double shiftAmountInMilis) { - return new DoubleVectorFrameSource(pmis, - pmis.getFormat().sampleCountForMiliseconds(frameSizeInMilis), - pmis.getFormat().sampleCountForMiliseconds(shiftAmountInMilis), - false); - } - - public static DoubleVectorFrameSource fromSizeInMilisecondsWithPadding( - PcmMonoInputStream pmis, double frameSizeInMilis, double shiftAmountInMilis) { - return new DoubleVectorFrameSource(pmis, - pmis.getFormat().sampleCountForMiliseconds(frameSizeInMilis), - pmis.getFormat().sampleCountForMiliseconds(shiftAmountInMilis), - true); - } - - public Iterable getIterableFrameReader() { - return new Iterable() { - public Iterator iterator() { - return new NormalizedFrameIterator(pmis, frameSize, shiftAmount, paddingApplied); - } - }; - } - - public Iterator getNormalizedFrameIterator() { - return new NormalizedFrameIterator(pmis, frameSize, shiftAmount, paddingApplied); - } - - public PcmMonoInputStream getPmis() { - return pmis; - } - - public int getFrameSize() { - return frameSize; - } - - public int getShiftAmount() { - return shiftAmount; - } - - public boolean isPaddingApplied() { - return paddingApplied; - } -} diff --git a/SaidIt/src/main/java/simplesound/dsp/DoubleVectorProcessingPipeline.java b/SaidIt/src/main/java/simplesound/dsp/DoubleVectorProcessingPipeline.java deleted file mode 100644 index df23af41..00000000 --- a/SaidIt/src/main/java/simplesound/dsp/DoubleVectorProcessingPipeline.java +++ /dev/null @@ -1,16 +0,0 @@ -package simplesound.dsp; - -import java.util.Iterator; -import java.util.List; - -public class DoubleVectorProcessingPipeline { - - List processors; - Iterator vectorSource; - - public DoubleVectorProcessingPipeline(Iterator vectorSource, - List processors) { - this.vectorSource = vectorSource; - this.processors = processors; - } -} diff --git a/SaidIt/src/main/java/simplesound/dsp/DoubleVectorProcessor.java b/SaidIt/src/main/java/simplesound/dsp/DoubleVectorProcessor.java deleted file mode 100644 index 98b06afa..00000000 --- a/SaidIt/src/main/java/simplesound/dsp/DoubleVectorProcessor.java +++ /dev/null @@ -1,8 +0,0 @@ -package simplesound.dsp; - -public interface DoubleVectorProcessor { - - DoubleVector process(DoubleVector input); - - void processInPlace(DoubleVector input); -} diff --git a/SaidIt/src/main/java/simplesound/dsp/MutableComplex.java b/SaidIt/src/main/java/simplesound/dsp/MutableComplex.java deleted file mode 100644 index 1c6ad1e8..00000000 --- a/SaidIt/src/main/java/simplesound/dsp/MutableComplex.java +++ /dev/null @@ -1,20 +0,0 @@ -package simplesound.dsp; - -public class MutableComplex { - public double real; - public double imaginary; - - public MutableComplex(double real, double imaginary) { - this.real = real; - this.imaginary = imaginary; - } - - public MutableComplex(Complex complex) { - this.real = complex.real; - this.imaginary = complex.imaginary; - } - - public Complex getImmutableComplex() { - return new Complex(real, imaginary); - } -} diff --git a/SaidIt/src/main/java/simplesound/dsp/NormalizedFrameIterator.java b/SaidIt/src/main/java/simplesound/dsp/NormalizedFrameIterator.java deleted file mode 100644 index c98a196a..00000000 --- a/SaidIt/src/main/java/simplesound/dsp/NormalizedFrameIterator.java +++ /dev/null @@ -1,77 +0,0 @@ -package simplesound.dsp; - -import simplesound.dsp.DoubleVector; -import simplesound.pcm.PcmMonoInputStream; - -import java.io.IOException; -import java.util.Iterator; - -public class NormalizedFrameIterator implements Iterator { - - private final PcmMonoInputStream pmis; - private final int frameSize; - private final int shiftAmount; - //TODO: not applied yet - private final boolean applyPadding; - - public NormalizedFrameIterator(PcmMonoInputStream pmis, int frameSize, int shiftAmount, boolean applyPadding) { - if (frameSize < 1) - throw new IllegalArgumentException("Frame size must be larger than zero."); - if (shiftAmount < 1) - throw new IllegalArgumentException("Shift size must be larger than zero."); - this.pmis = pmis; - this.frameSize = frameSize; - this.shiftAmount = shiftAmount; - this.applyPadding = applyPadding; - } - - public NormalizedFrameIterator(PcmMonoInputStream pmis, int frameSize, boolean applyPadding) { - this(pmis, frameSize, frameSize, applyPadding); - } - - public NormalizedFrameIterator(PcmMonoInputStream pmis, int frameSize) { - this(pmis, frameSize, frameSize, false); - } - - private DoubleVector currentFrame; - private int frameCounter; - - public boolean hasNext() { - double[] data; - try { - if (frameCounter == 0) { - data = pmis.readSamplesNormalized(frameSize); - if (data.length < frameSize) - return false; - currentFrame = new DoubleVector(data); - } else { - data = pmis.readSamplesNormalized(shiftAmount); - if (data.length < shiftAmount) - return false; - double[] frameData = currentFrame.data.clone(); - System.arraycopy(data, 0, frameData, frameData.length - shiftAmount, shiftAmount); - currentFrame = new DoubleVector(frameData); - } - } catch (IOException e) { - return false; - } - frameCounter++; - return true; - } - - public DoubleVector next() { - return currentFrame; - } - - public void remove() { - throw new UnsupportedOperationException("Remove is not supported."); - } - - public int getFrameSize() { - return frameSize; - } - - public int getShiftAmount() { - return shiftAmount; - } -} diff --git a/SaidIt/src/main/java/simplesound/dsp/WindowerFactory.java b/SaidIt/src/main/java/simplesound/dsp/WindowerFactory.java deleted file mode 100644 index d7eade26..00000000 --- a/SaidIt/src/main/java/simplesound/dsp/WindowerFactory.java +++ /dev/null @@ -1,45 +0,0 @@ -package simplesound.dsp; - -import org.jcaki.Doubles; - -import static java.lang.Math.PI; -import static java.lang.Math.cos; - -public class WindowerFactory { - - private static class RaisedCosineWindower implements DoubleVectorProcessor { - double alpha; - double cosineWindow[]; - - RaisedCosineWindower(double alpha, int length) { - if (length <= 0) - throw new IllegalArgumentException("Window length cannot be smaller than 1"); - this.alpha = alpha; - cosineWindow = new double[length]; - for (int i = 0; i < length; i++) { - cosineWindow[i] = (1 - alpha) - alpha * cos(2 * PI * i / ((double) length - 1.0)); - } - } - - public DoubleVector process(DoubleVector input) { - return new DoubleVector(Doubles.multiply(input.data, cosineWindow)); - } - - public void processInPlace(DoubleVector input) { - Doubles.multiplyInPlace(input.data, cosineWindow); - } - } - - public static DoubleVectorProcessor newHammingWindower(int length) { - return new RaisedCosineWindower(0.46d, length); - } - - public static DoubleVectorProcessor newHanningWindower(int length) { - return new RaisedCosineWindower(0.5d, length); - } - - public static DoubleVectorProcessor newTriangularWindower(int length) { - return new RaisedCosineWindower(0.0d, length); - } - -} diff --git a/SaidIt/src/main/java/simplesound/pcm/MonoWavFileReader.java b/SaidIt/src/main/java/simplesound/pcm/MonoWavFileReader.java deleted file mode 100644 index bca038dc..00000000 --- a/SaidIt/src/main/java/simplesound/pcm/MonoWavFileReader.java +++ /dev/null @@ -1,76 +0,0 @@ -package simplesound.pcm; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; - -public class MonoWavFileReader { - - private final File file; - private final RiffHeaderData riffHeaderData; - - public MonoWavFileReader(String fileName) throws IOException { - this(new File(fileName)); - } - - public MonoWavFileReader(File file) throws IOException { - this.file = file; - riffHeaderData = new RiffHeaderData(file); - if (riffHeaderData.getFormat().getChannels() != 1) - throw new IllegalArgumentException("Wav file is not Mono."); - } - - public PcmMonoInputStream getNewStream() throws IOException { - PcmMonoInputStream asis = new PcmMonoInputStream( - riffHeaderData.getFormat(), - new FileInputStream(file)); - long amount = asis.skip(RiffHeaderData.PCM_RIFF_HEADER_SIZE); - if (amount < RiffHeaderData.PCM_RIFF_HEADER_SIZE) - throw new IllegalArgumentException("cannot skip necessary amount of bytes from underlying stream."); - return asis; - } - - private void validateFrameBoundaries(int frameStart, int frameEnd) { - if (frameStart < 0) - throw new IllegalArgumentException("Start Frame cannot be negative:" + frameStart); - if (frameEnd < frameStart) - throw new IllegalArgumentException("Start Frame cannot be after end frame. Start:" - + frameStart + ", end:" + frameEnd); - if (frameEnd > riffHeaderData.getSampleCount()) - throw new IllegalArgumentException("Frame count out of bounds. Max sample count:" - + riffHeaderData.getSampleCount() + " but frame is:" + frameEnd); - } - - public int[] getAllSamples() throws IOException { - PcmMonoInputStream stream = getNewStream(); - try { - return stream.readAll(); - } finally { - stream.close(); - } - } - - public int[] getSamplesAsInts(int frameStart, int frameEnd) throws IOException { - validateFrameBoundaries(frameStart, frameEnd); - PcmMonoInputStream stream = getNewStream(); - try { - stream.skipSamples(frameStart); - return stream.readSamplesAsIntArray(frameEnd - frameStart); - } finally { - stream.close(); - } - } - - - public PcmAudioFormat getFormat() { - return riffHeaderData.getFormat(); - } - - public int getSampleCount() { - return riffHeaderData.getSampleCount(); - } - - public File getFile() { - return file; - } -} diff --git a/SaidIt/src/main/java/simplesound/pcm/PcmAudioFormat.java b/SaidIt/src/main/java/simplesound/pcm/PcmAudioFormat.java deleted file mode 100644 index 005478bc..00000000 --- a/SaidIt/src/main/java/simplesound/pcm/PcmAudioFormat.java +++ /dev/null @@ -1,139 +0,0 @@ -package simplesound.pcm; - -/** - * Represents paramters for raw pcm audio sample data. - * Channels represents mono or stereo data. mono=1, stereo=2 - */ -public class PcmAudioFormat { - - /** - * Sample frequency in sample/sec. - */ - private final int sampleRate; - /** - * the amount of bits representing samples. - */ - private final int sampleSizeInBits; - /** - * How many bytes are required for representing samples - */ - private final int bytesRequiredPerSample; - /** - * channels. For now only 1 or two channels are allowed. - */ - private final int channels; - /** - * if data is represented as big endian or little endian. - */ - protected final boolean bigEndian; - /** - * if data is signed or unsigned. - */ - private final boolean signed; - - protected PcmAudioFormat(int sampleRate, int sampleSizeInBits, int channels, boolean bigEndian, boolean signed) { - - if (sampleRate < 1) - throw new IllegalArgumentException("sampleRate cannot be less than one. But it is:" + sampleRate); - this.sampleRate = sampleRate; - - if (sampleSizeInBits < 2 || sampleSizeInBits > 31) { - throw new IllegalArgumentException("sampleSizeInBits must be between (including) 2-31. But it is:" + sampleSizeInBits); - } - this.sampleSizeInBits = sampleSizeInBits; - - if (channels < 1 || channels > 2) { - throw new IllegalArgumentException("channels must be 1 or 2. But it is:" + channels); - } - this.channels = channels; - - this.bigEndian = bigEndian; - this.signed = signed; - if (sampleSizeInBits % 8 == 0) - bytesRequiredPerSample = sampleSizeInBits / 8; - else - bytesRequiredPerSample = sampleSizeInBits / 8 + 1; - } - - /** - * This is a builder class. By default it generates little endian, mono, signed, 16 bits per sample. - */ - public static class Builder { - private int _sampleRate; - private int _sampleSizeInBits = 16; - private int _channels = 1; - private boolean _bigEndian = false; - private boolean _signed = true; - - public Builder(int sampleRate) { - this._sampleRate = sampleRate; - } - - public Builder channels(int channels) { - this._channels = channels; - return this; - } - - public Builder bigEndian() { - this._bigEndian = true; - return this; - } - - public Builder unsigned() { - this._signed = false; - return this; - } - - public Builder sampleSizeInBits(int sampleSizeInBits) { - this._sampleSizeInBits = sampleSizeInBits; - return this; - } - - public PcmAudioFormat build() { - return new PcmAudioFormat(_sampleRate, _sampleSizeInBits, _channels, _bigEndian, _signed); - } - } - - PcmAudioFormat mono16BitSignedLittleEndian(int sampleRate) { - return new PcmAudioFormat(sampleRate, 16, 1, false, true); - } - - public int getSampleRate() { - return sampleRate; - } - - public int getChannels() { - return channels; - } - - public int getSampleSizeInBits() { - return sampleSizeInBits; - } - - /** - * returns the required bytes for the sample bit size. Such that, if 4 or 8 bit samples are used. - * it returns 1, if 12 bit used 2 returns. - * - * @return required byte amount for the sample size in bits. - */ - public int getBytePerSample() { - return bytesRequiredPerSample; - } - - public boolean isBigEndian() { - return bigEndian; - } - - public boolean isSigned() { - return signed; - } - - public int sampleCountForMiliseconds(double miliseconds) { - return (int) ((double) sampleRate * miliseconds / 1000d); - } - - public String toString() { - return "[ Sample Rate:" + sampleRate + " , SampleSizeInBits:" + sampleSizeInBits + - ", channels:" + channels + ", signed:" + signed + ", bigEndian:" + bigEndian + " ]"; - } -} diff --git a/SaidIt/src/main/java/simplesound/pcm/PcmAudioHelper.java b/SaidIt/src/main/java/simplesound/pcm/PcmAudioHelper.java deleted file mode 100644 index 875919a3..00000000 --- a/SaidIt/src/main/java/simplesound/pcm/PcmAudioHelper.java +++ /dev/null @@ -1,68 +0,0 @@ -package simplesound.pcm; - -import static org.jcaki.Bytes.toByteArray; -import org.jcaki.IOs; - -import java.io.*; - -public class PcmAudioHelper { - - /** - * Converts a pcm encoded raw audio stream to a wav file. - * - * @param af format - * @param rawSource raw source file - * @param wavTarget raw file target - * @throws IOException thrown if an error occurs during file operations. - */ - public static void convertRawToWav(WavAudioFormat af, File rawSource, File wavTarget) throws IOException { - DataOutputStream dos = new DataOutputStream(new FileOutputStream(wavTarget)); - dos.write(new RiffHeaderData(af, 0).asByteArray()); - DataInputStream dis = new DataInputStream(new FileInputStream(rawSource)); - byte[] buffer = new byte[4096]; - int i; - int total = 0; - while ((i = dis.read(buffer)) != -1) { - total += i; - dos.write(buffer, 0, i); - } - dos.close(); - modifyRiffSizeData(wavTarget, total); - } - - public static void convertWavToRaw(File wavSource, File rawTarget) throws IOException { - IOs.copy(new MonoWavFileReader(wavSource).getNewStream(), new FileOutputStream(rawTarget)); - } - - public static double[] readAllFromWavNormalized(String fileName) throws IOException { - return new MonoWavFileReader(new File(fileName)).getNewStream().readSamplesNormalized(); - } - - /** - * Modifies the size information in a wav file header. - * - * @param wavFile a wav file - * @param size size to replace the header. - * @throws IOException if an error occurs whule accesing the data. - */ - static void modifyRiffSizeData(File wavFile, int size) throws IOException { - RandomAccessFile raf = new RandomAccessFile(wavFile, "rw"); - raf.seek(RiffHeaderData.RIFF_CHUNK_SIZE_INDEX); - raf.write(toByteArray(size + 36, false)); - raf.seek(RiffHeaderData.RIFF_SUBCHUNK2_SIZE_INDEX); - raf.write(toByteArray(size, false)); - raf.close(); - } - - public static void generateSilenceWavFile(WavAudioFormat wavAudioFormat, File file, double sec) throws IOException { - WavFileWriter wfr = new WavFileWriter(wavAudioFormat, file); - int[] empty = new int[(int) (sec * wavAudioFormat.getSampleRate())]; - try { - wfr.write(empty); - } finally { - wfr.close(); - } - } - -} - diff --git a/SaidIt/src/main/java/simplesound/pcm/PcmMonoInputStream.java b/SaidIt/src/main/java/simplesound/pcm/PcmMonoInputStream.java deleted file mode 100644 index 33b5c55d..00000000 --- a/SaidIt/src/main/java/simplesound/pcm/PcmMonoInputStream.java +++ /dev/null @@ -1,163 +0,0 @@ -package simplesound.pcm; - -import org.jcaki.Bytes; -import org.jcaki.IOs; - -import java.io.Closeable; -import java.io.DataInputStream; -import java.io.IOException; -import java.io.InputStream; - -public class PcmMonoInputStream extends InputStream implements Closeable { - - private final PcmAudioFormat format; - private final DataInputStream dis; - /** - * this is used for normalization. - */ - private final int maxPositiveIntegerForSampleSize; - - - public PcmMonoInputStream(PcmAudioFormat format, InputStream is) { - if (format.getChannels() != 1) - throw new IllegalArgumentException("Only mono streams are supported."); - this.format = format; - this.dis = new DataInputStream(is); - this.maxPositiveIntegerForSampleSize = 0x7fffffff >>> (32 - format.getSampleSizeInBits()); - } - - public int read() throws IOException { - return dis.read(); - } - - public int[] readSamplesAsIntArray(int amount) throws IOException { - byte[] bytez = new byte[amount * format.getBytePerSample()]; - int readAmount = dis.read(bytez); - if (readAmount == -1) - return new int[0]; - return Bytes.toReducedBitIntArray( - bytez, - readAmount, - format.getBytePerSample(), - format.getSampleSizeInBits(), - format.isBigEndian()); - } - - public int[] readAll() throws IOException { - byte[] all = IOs.readAsByteArray(dis); - return Bytes.toReducedBitIntArray( - all, - all.length, - format.getBytePerSample(), - format.getSampleSizeInBits(), - format.isBigEndian()); - } - - private static final int BYTE_BUFFER_SIZE = 4096; - - /** - * reads samples as byte array. if there is not enough data for the amount of samples, remaining data is returned - * anyway. if the byte amount is not an order of bytes required for sample (such as 51 bytes left but 16 bit samples) - * an IllegalStateException is thrown. - * - * @param amount amount of samples to read. - * @return byte array. - * @throws IOException if there is an IO error. - * @throws IllegalStateException if the amount of bytes read is not an order of correct. - */ - public byte[] readSamplesAsByteArray(int amount) throws IOException { - - byte[] bytez = new byte[amount * format.getBytePerSample()]; - int readCount = dis.read(bytez); - if (readCount != bytez.length) { - validateReadCount(readCount); - byte[] result = new byte[readCount]; - System.arraycopy(bytez, 0, result, 0, readCount); - return result; - } else - return bytez; - } - - private void validateReadCount(int readCount) { - if (readCount % format.getBytePerSample() != 0) - throw new IllegalStateException("unexpected amounts of bytes read from the input stream. " + - "Byte count must be an order of:" + format.getBytePerSample()); - } - - public int[] readSamplesAsIntArray(int frameStart, int frameEnd) throws IOException { - skipSamples(frameStart * format.getBytePerSample()); - return readSamplesAsIntArray(frameEnd - frameStart); - } - - /** - * skips samples from the stream. if end of file is reached, it returns the amount that is actually skipped. - * - * @param skipAmount amount of samples to skip - * @return actual skipped sample count. - * @throws IOException if there is a problem while skipping. - */ - public int skipSamples(int skipAmount) throws IOException { - long actualSkipped = dis.skip(skipAmount * format.getBytePerSample()); - return (int) actualSkipped / format.getBytePerSample(); - } - - public double[] readSamplesNormalized(int amount) throws IOException { - return normalize(readSamplesAsIntArray(amount)); - } - - public double[] readSamplesNormalized() throws IOException { - return normalize(readAll()); - } - - private double[] normalize(int[] original) { - if (original.length == 0) - return new double[0]; - double[] normalized = new double[original.length]; - for (int i = 0; i < normalized.length; i++) { - normalized[i] = (double) original[i] / maxPositiveIntegerForSampleSize; - } - return normalized; - } - - public void close() throws IOException { - dis.close(); - } - - /** - * finds the byte location of a given time. if time is negative, exception is thrown. - * - * @param second second information - * @return the byte location in the samples. - */ - public int calculateSampleByteIndex(double second) { - - if (second < 0) - throw new IllegalArgumentException("Time information cannot be negative."); - - int loc = (int) (second * format.getSampleRate() * format.getBytePerSample()); - - //byte alignment. - if (loc % format.getBytePerSample() != 0) { - loc += (format.getBytePerSample() - loc % format.getBytePerSample()); - } - return loc; - } - - /** - * calcualates the time informationn for a given sample. - * - * @param sampleIndex sample index. - * @return approximate seconds information for the given sample. - */ - public double calculateSampleTime(int sampleIndex) { - if (sampleIndex < 0) - throw new IllegalArgumentException("sampleIndex information cannot be negative:" + sampleIndex); - - return (double) sampleIndex / format.getSampleRate(); - } - - public PcmAudioFormat getFormat() { - return format; - } -} - diff --git a/SaidIt/src/main/java/simplesound/pcm/PcmMonoOutputStream.java b/SaidIt/src/main/java/simplesound/pcm/PcmMonoOutputStream.java deleted file mode 100644 index bc79fcc6..00000000 --- a/SaidIt/src/main/java/simplesound/pcm/PcmMonoOutputStream.java +++ /dev/null @@ -1,43 +0,0 @@ -package simplesound.pcm; - -import org.jcaki.Bytes; -import org.jcaki.IOs; - -import java.io.*; - -public class PcmMonoOutputStream extends OutputStream implements Closeable { - - final PcmAudioFormat format; - final DataOutputStream dos; - - public PcmMonoOutputStream(PcmAudioFormat format, DataOutputStream dos) { - this.format = format; - this.dos = dos; - } - - public PcmMonoOutputStream(PcmAudioFormat format, File file) throws IOException { - this.format = format; - this.dos = new DataOutputStream(new FileOutputStream(file)); - } - - public void write(int b) throws IOException { - dos.write(b); - } - - @Override - public void write(byte[] buffer, int offset, int count) throws IOException { - dos.write(buffer, offset, count); - } - - public void write(short[] shorts) throws IOException { - dos.write(Bytes.toByteArray(shorts, shorts.length, format.isBigEndian())); - } - - public void write(int[] ints) throws IOException { - dos.write(Bytes.toByteArray(ints, ints.length, format.getBytePerSample(), format.isBigEndian())); - } - - public void close() { - IOs.closeSilently(dos); - } -} diff --git a/SaidIt/src/main/java/simplesound/pcm/RiffHeaderData.java b/SaidIt/src/main/java/simplesound/pcm/RiffHeaderData.java deleted file mode 100644 index 9f51ecc4..00000000 --- a/SaidIt/src/main/java/simplesound/pcm/RiffHeaderData.java +++ /dev/null @@ -1,129 +0,0 @@ -package simplesound.pcm; - -import static org.jcaki.Bytes.toByteArray; -import static org.jcaki.Bytes.toInt; -import org.jcaki.IOs; - -import java.io.*; - -class RiffHeaderData { - - public static final int PCM_RIFF_HEADER_SIZE = 44; - public static final int RIFF_CHUNK_SIZE_INDEX = 4; - public static final int RIFF_SUBCHUNK2_SIZE_INDEX = 40; - - private final PcmAudioFormat format; - private final int totalSamplesInByte; - - public RiffHeaderData(PcmAudioFormat format, int totalSamplesInByte) { - this.format = format; - this.totalSamplesInByte = totalSamplesInByte; - } - - public double timeSeconds() { - return (double) totalSamplesInByte / format.getBytePerSample() / format.getSampleRate(); - } - - public RiffHeaderData(DataInputStream dis) throws IOException { - - try { - byte[] buf4 = new byte[4]; - byte[] buf2 = new byte[2]; - - dis.skipBytes(4 + 4 + 4 + 4 + 4 + 2); - - dis.readFully(buf2); - final int channels = toInt(buf2, false); - - dis.readFully(buf4); - final int sampleRate = toInt(buf4, false); - - dis.skipBytes(4 + 2); - - dis.readFully(buf2); - final int sampleSizeInBits = toInt(buf2, false); - - dis.skipBytes(4); - - dis.readFully(buf4); - totalSamplesInByte = toInt(buf4, false); - - format = new WavAudioFormat.Builder(). - channels(channels). - sampleRate(sampleRate). - sampleSizeInBits(sampleSizeInBits). - build(); - } finally { - IOs.closeSilently(dis); - } - } - - public RiffHeaderData(File file) throws IOException { - this(new DataInputStream(new FileInputStream(file))); - } - - public byte[] asByteArray() { - ByteArrayOutputStream baos = null; - try { - baos = new ByteArrayOutputStream(); - // ChunkID (the String "RIFF") 4 Bytes - baos.write(toByteArray(0x52494646, true)); - // ChunkSize (Whole file size in byte minus 8 bytes ) , or (4 + (8 + SubChunk1Size) + (8 + SubChunk2Size)) - // little endian 4 Bytes. - baos.write(toByteArray(36 + totalSamplesInByte, false)); - // Format (the String "WAVE") 4 Bytes big endian - baos.write(toByteArray(0x57415645, true)); - - // Subchunk1 - // Subchunk1ID (the String "fmt ") 4 bytes big endian. - baos.write(toByteArray(0x666d7420, true)); - // Subchunk1Size. 16 for the PCM. little endian 4 bytes. - baos.write(toByteArray(16, false)); - // AudioFormat , for PCM = 1, Little endian 2 Bytes. - baos.write(toByteArray((short) 1, false)); - // Number of channels Mono = 1, Stereo = 2 Little Endian , 2 bytes. - int channels = format.getChannels(); - baos.write(toByteArray((short) channels, false)); - // SampleRate (8000, 44100 etc.) little endian, 4 bytes - int sampleRate = format.getSampleRate(); - baos.write(toByteArray(sampleRate, false)); - // byte rate (SampleRate * NumChannels * BitsPerSample/8) little endian, 4 bytes. - baos.write(toByteArray(channels * sampleRate * format.getBytePerSample(), false)); - // Block Allign == NumChannels * BitsPerSample/8 The number of bytes for one sample including all channels. LE, 2 bytes - baos.write(toByteArray((short) (channels * format.getBytePerSample()), false)); - // BitsPerSample (8, 16 etc.) LE, 2 bytes - baos.write(toByteArray((short) format.getSampleSizeInBits(), false)); - - // Subchunk2 - // SubChunk2ID (String "data") 4 bytes. - baos.write(toByteArray(0x64617461, true)); - // Subchunk2Size == NumSamples * NumChannels * BitsPerSample/8. This is the number of bytes in the data. - // You can also think of this as the size of the read of the subchunk following this number. LE, 4 bytes. - baos.write(toByteArray(totalSamplesInByte, false)); - - return baos.toByteArray(); - } catch (IOException e) { - e.printStackTrace(); - return new byte[0]; - } finally { - IOs.closeSilently(baos); - } - } - - public PcmAudioFormat getFormat() { - return format; - } - - public int getTotalSamplesInByte() { - return totalSamplesInByte; - } - - public int getSampleCount() { - return totalSamplesInByte / format.getBytePerSample(); - } - - public String toString() { - return "[ Format: " + format.toString() + " , totalSamplesInByte:" + totalSamplesInByte + "]"; - } -} - diff --git a/SaidIt/src/main/java/simplesound/pcm/WavAudioFormat.java b/SaidIt/src/main/java/simplesound/pcm/WavAudioFormat.java deleted file mode 100644 index b467f851..00000000 --- a/SaidIt/src/main/java/simplesound/pcm/WavAudioFormat.java +++ /dev/null @@ -1,69 +0,0 @@ -package simplesound.pcm; - -public class WavAudioFormat extends PcmAudioFormat { - - /** - * if data is represented as big endian or little endian. - */ - protected final boolean bigEndian = false; - - private WavAudioFormat(int sampleRate, int sampleSizeInBits, int channels, boolean signed) { - super(sampleRate, sampleSizeInBits, channels, false, signed); - } - - /** - * a builder class for generating PCM Audio format for wav files. - */ - public static class Builder { - private int _sampleRate; - private int _sampleSizeInBits = 16; - private int _channels = 1; - - public Builder sampleRate(int sampleRate) { - this._sampleRate = sampleRate; - return this; - } - - public Builder channels(int channels) { - this._channels = channels; - return this; - } - - public Builder sampleSizeInBits(int sampleSizeInBits) { - this._sampleSizeInBits = sampleSizeInBits; - return this; - } - - public WavAudioFormat build() { - if (_sampleSizeInBits == 8) - return new WavAudioFormat(_sampleRate, _sampleSizeInBits, _channels, false); - else - return new WavAudioFormat(_sampleRate, _sampleSizeInBits, _channels, true); - } - } - - /** - * generates a PcmAudioFormat for wav files for 16 bits signed mono data. - * - * @param sampleRate sampling rate. - * @return new PcmAudioFormat object for given wav header values. . - */ - public static WavAudioFormat mono16Bit(int sampleRate) { - return new WavAudioFormat(sampleRate, 16, 1, true); - } - - /** - * Generates audio format data for Wav audio format. returning PCM format is little endian. - * - * @param sampleRate sample rate - * @param sampleSizeInBits bit amount per sample - * @param channels channel count. can be 1 or 2 - * @return a RawAudioFormat suitable for wav format. - */ - public static WavAudioFormat wavFormat(int sampleRate, int sampleSizeInBits, int channels) { - if (sampleSizeInBits == 8) - return new WavAudioFormat(sampleRate, sampleSizeInBits, channels, false); - else - return new WavAudioFormat(sampleRate, sampleSizeInBits, channels, true); - } -} diff --git a/SaidIt/src/main/java/simplesound/pcm/WavFileWriter.java b/SaidIt/src/main/java/simplesound/pcm/WavFileWriter.java deleted file mode 100644 index defff5f9..00000000 --- a/SaidIt/src/main/java/simplesound/pcm/WavFileWriter.java +++ /dev/null @@ -1,86 +0,0 @@ -package simplesound.pcm; - -import android.util.Log; - -import java.io.Closeable; -import java.io.File; -import java.io.IOException; - -/** - * Writes a wav file. Careful that it writes the total amount of the bytes information once the close method - * is called. It has a counter in it to calculate the samle size. - */ -public class WavFileWriter implements Closeable { - - private final WavAudioFormat pcmAudioFormat; - private final PcmMonoOutputStream pos; - private int totalSampleBytesWritten = 0; - private final File file; - - public WavFileWriter(WavAudioFormat wavAudioFormat, File file) throws IOException { - if (wavAudioFormat.isBigEndian()) - throw new IllegalArgumentException("Wav file cannot contain bigEndian sample data."); - if (wavAudioFormat.getSampleSizeInBits() > 8 && !wavAudioFormat.isSigned()) - throw new IllegalArgumentException("Wav file cannot contain unsigned data for this sampleSize:" - + wavAudioFormat.getSampleSizeInBits()); - this.pcmAudioFormat = wavAudioFormat; - this.file = file; - this.pos = new PcmMonoOutputStream(wavAudioFormat, file); - pos.write(new RiffHeaderData(wavAudioFormat, 0).asByteArray()); - } - - public WavFileWriter write(byte[] bytes) throws IOException { - checkLimit(totalSampleBytesWritten, bytes.length); - pos.write(bytes); - totalSampleBytesWritten += bytes.length; - return this; - } - - public WavFileWriter write(byte[] bytes, int offset, int count) throws IOException { - checkLimit(totalSampleBytesWritten, count); - pos.write(bytes, offset, count); - totalSampleBytesWritten += count; - return this; - } - - private void checkLimit(int total, int toAdd) { - final long result = total + toAdd; - if (result >= Integer.MAX_VALUE) { - throw new IllegalStateException("Size of bytes is too big:" + result); - } - } - - public WavFileWriter write(int[] samples) throws IOException { - final int bytePerSample = pcmAudioFormat.getBytePerSample(); - checkLimit(totalSampleBytesWritten, samples.length * bytePerSample); - pos.write(samples); - totalSampleBytesWritten += samples.length * bytePerSample; - return this; - } - - public WavFileWriter write(short[] samples) throws IOException { - checkLimit(totalSampleBytesWritten, samples.length * 2); - pos.write(samples); - totalSampleBytesWritten += samples.length * 2; - return this; - } - - WavFileWriter writeNormalized(double[] samples) throws IOException { - return this; - } - - public void close() throws IOException { - pos.close(); - PcmAudioHelper.modifyRiffSizeData(file, totalSampleBytesWritten); - } - - public PcmAudioFormat getWavFormat() { - return pcmAudioFormat; - } - - - public int getTotalSampleBytesWritten() { - return totalSampleBytesWritten; - } -} - diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToActivity.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToActivity.kt new file mode 100644 index 00000000..5518d480 --- /dev/null +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToActivity.kt @@ -0,0 +1,31 @@ +package eu.mrogalski.saidit + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import com.siya.epistemophile.R + +class HowToActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_how_to) + + val toolbar = findViewById(R.id.toolbar) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + toolbar.setNavigationOnClickListener { finish() } + + val viewPager = findViewById(R.id.view_pager) + val tabLayout = findViewById(R.id.tab_layout) + + viewPager.adapter = HowToPagerAdapter(this) + + TabLayoutMediator(tabLayout, viewPager) { tab, position -> + tab.text = getString(R.string.how_to_step_tab_title, position + 1) + }.attach() + } +} diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToPageFragment.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToPageFragment.kt new file mode 100644 index 00000000..b79aa1ba --- /dev/null +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToPageFragment.kt @@ -0,0 +1,28 @@ +package eu.mrogalski.saidit + +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import com.siya.epistemophile.R + +class HowToPageFragment : Fragment(R.layout.fragment_how_to_page) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val stepIndex = requireArguments().getInt(ARG_STEP_INDEX) + val step = HowToStep.fromIndex(stepIndex) + + view.findViewById(R.id.how_to_title_text).setText(step.titleRes) + view.findViewById(R.id.how_to_description_text).setText(step.descriptionRes) + } + + companion object { + private const val ARG_STEP_INDEX = "step_index" + + fun newInstance(index: Int): HowToPageFragment = HowToPageFragment().apply { + arguments = bundleOf(ARG_STEP_INDEX to index) + } + } +} diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToPagerAdapter.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToPagerAdapter.kt new file mode 100644 index 00000000..d928923b --- /dev/null +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToPagerAdapter.kt @@ -0,0 +1,12 @@ +package eu.mrogalski.saidit + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter + +internal class HowToPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { + + override fun getItemCount(): Int = HowToStep.entries.size + + override fun createFragment(position: Int): Fragment = HowToPageFragment.newInstance(position) +} diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToStep.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToStep.kt new file mode 100644 index 00000000..feead2da --- /dev/null +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/HowToStep.kt @@ -0,0 +1,22 @@ +package eu.mrogalski.saidit + +import androidx.annotation.StringRes +import com.siya.epistemophile.R + +internal enum class HowToStep( + @StringRes val titleRes: Int, + @StringRes val descriptionRes: Int +) { + OVERVIEW(R.string.how_to_title_1, R.string.how_to_desc_1), + SAVE_CLIP(R.string.how_to_title_2, R.string.how_to_desc_2), + MANAGE_RECORDINGS(R.string.how_to_title_3, R.string.how_to_desc_3); + + companion object { + fun fromIndex(index: Int): HowToStep { + require(index in entries.indices) { + "Step index $index is out of bounds" + } + return entries[index] + } + } +} diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt new file mode 100644 index 00000000..b0629440 --- /dev/null +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt @@ -0,0 +1,336 @@ +package eu.mrogalski.saidit + +import android.content.Context +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.siya.epistemophile.R +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +class RecordingsAdapter @JvmOverloads constructor( + private val context: Context, + recordings: List, + private val playbackSessionFactory: PlaybackSessionFactory = PlaybackSessionFactory { MediaPlayerPlaybackSession(context) }, + private val nowProvider: () -> Long = { System.currentTimeMillis() }, + private val deleteRecording: (Uri) -> Boolean = { uri -> + context.contentResolver.delete(uri, null, null) > 0 + } +) : RecyclerView.Adapter() { + + private val items: MutableList = buildItems(recordings) + + @VisibleForTesting + internal fun snapshotLabels(): List = items.map { + when (it) { + is RecordingListItem.Header -> "H:${it.title}" + is RecordingListItem.Entry -> "R:${it.recording.name}" + } + } + + private val dateFormatter = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) + + private var playbackSession: PlaybackSession? = null + private var playingPosition: Int? = null + + override fun getItemCount(): Int = items.size + + override fun getItemViewType(position: Int): Int = when (items[position]) { + is RecordingListItem.Header -> VIEW_TYPE_HEADER + is RecordingListItem.Entry -> VIEW_TYPE_RECORDING + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_HEADER -> { + val view = inflater.inflate(R.layout.list_item_header, parent, false) + HeaderViewHolder(view) + } + else -> { + val view = inflater.inflate(R.layout.list_item_recording, parent, false) + RecordingViewHolder(view) + } + } + } + + override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { + when (holder) { + is HeaderViewHolder -> holder.bind(items[position] as RecordingListItem.Header) + is RecordingViewHolder -> { + val entry = items[position] as RecordingListItem.Entry + holder.bind(entry) + holder.updatePlayButton(position == playingPosition) + } + } + } + + fun releasePlayer() { + val hadSession = playbackSession != null + releasePlayback() + playingPosition = null + if (hadSession) { + notifyDataSetChanged() + } + } + + private fun onPlaybackRequested(holder: RecordingViewHolder, position: Int) { + if (position == RecyclerView.NO_POSITION) return + val entry = items.getOrNull(position) as? RecordingListItem.Entry ?: return + + val currentSession = playbackSession + if (playingPosition == position && currentSession != null) { + if (currentSession.isPlaying) { + currentSession.pause() + holder.updatePlayButton(false) + } else { + currentSession.start() + holder.updatePlayButton(true) + } + return + } + + val previousPosition = playingPosition + releasePlayback() + + val session = playbackSessionFactory.create() + try { + session.prepare(entry.recording.uri) + session.setOnCompletionListener { + playingPosition = null + playbackSession = null + notifyItemChanged(position) + } + session.start() + playbackSession = session + playingPosition = position + holder.updatePlayButton(true) + previousPosition?.let { notifyItemChanged(it) } + } catch (ioException: IOException) { + playbackSession = null + playingPosition = null + holder.updatePlayButton(false) + } + } + + private fun onDeleteRequested(position: Int) { + if (position == RecyclerView.NO_POSITION) return + val entry = items.getOrNull(position) as? RecordingListItem.Entry ?: return + + MaterialAlertDialogBuilder(context) + .setTitle(R.string.recordings_delete_title) + .setMessage(R.string.recordings_delete_message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.recordings_delete_confirm) { _, _ -> + removeEntry(position, entry) + } + .show() + } + + private fun removeEntry(position: Int, entry: RecordingListItem.Entry) { + val removed = deleteRecording(entry.recording.uri) + if (!removed) { + return + } + + if (playingPosition == position) { + releasePlayback() + playingPosition = null + } + + playbackSession = null + + items.removeAt(position) + notifyItemRemoved(position) + adjustPlayingPositionAfterRemoval(position) + removeHeaderIfOrphaned(position) + } + + private fun adjustPlayingPositionAfterRemoval(removedIndex: Int) { + playingPosition = when (val current = playingPosition) { + null -> null + removedIndex -> null + else -> if (current > removedIndex) current - 1 else current + } + } + + private fun removeHeaderIfOrphaned(afterRemovalIndex: Int) { + val headerIndex = afterRemovalIndex - 1 + if (headerIndex < 0) return + if (items.getOrNull(headerIndex) !is RecordingListItem.Header) return + + val headerHasItems = items.getOrNull(headerIndex + 1) is RecordingListItem.Entry + if (!headerHasItems) { + items.removeAt(headerIndex) + notifyItemRemoved(headerIndex) + adjustPlayingPositionAfterRemoval(headerIndex) + } + } + + private fun releasePlayback() { + playbackSession?.release() + playbackSession = null + } + + private fun buildItems(recordings: List): MutableList { + if (recordings.isEmpty()) return mutableListOf() + + val result = mutableListOf() + var lastHeader: String? = null + recordings.forEach { recording -> + val header = headerLabel(recording.date) + if (header != lastHeader) { + result.add(RecordingListItem.Header(header)) + lastHeader = header + } + result.add(RecordingListItem.Entry(recording)) + } + return result + } + + private fun headerLabel(timestampSeconds: Long): String { + val now = Calendar.getInstance().apply { timeInMillis = nowProvider() } + val target = Calendar.getInstance().apply { + timeInMillis = TimeUnit.SECONDS.toMillis(timestampSeconds) + } + + val sameDay = now.get(Calendar.YEAR) == target.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) == target.get(Calendar.DAY_OF_YEAR) + if (sameDay) { + return context.getString(R.string.recordings_header_today) + } + + now.add(Calendar.DAY_OF_YEAR, -1) + val yesterday = now.get(Calendar.YEAR) == target.get(Calendar.YEAR) && + now.get(Calendar.DAY_OF_YEAR) == target.get(Calendar.DAY_OF_YEAR) + return if (yesterday) { + context.getString(R.string.recordings_header_yesterday) + } else { + dateFormatter.format(Date(TimeUnit.SECONDS.toMillis(timestampSeconds))) + } + } + + private fun formatInfo(recording: RecordingItem): String { + val duration = formatDuration(recording.duration) + val date = dateFormatter.format(Date(TimeUnit.SECONDS.toMillis(recording.date))) + return context.getString(R.string.recording_info_format, duration, date) + } + + private fun formatDuration(durationMillis: Long): String { + val minutes = TimeUnit.MILLISECONDS.toMinutes(durationMillis) + val seconds = TimeUnit.MILLISECONDS.toSeconds(durationMillis) - + TimeUnit.MINUTES.toSeconds(minutes) + return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + } + + abstract class BaseViewHolder(view: View) : RecyclerView.ViewHolder(view) { + internal abstract fun bind(item: RecordingListItem) + } + + private inner class HeaderViewHolder(view: View) : BaseViewHolder(view) { + private val headerText: TextView = view.findViewById(R.id.header_text_view) + + override fun bind(item: RecordingListItem) { + val header = item as RecordingListItem.Header + headerText.text = header.title + } + } + + private inner class RecordingViewHolder(view: View) : BaseViewHolder(view) { + private val nameText: TextView = view.findViewById(R.id.recording_name_text) + private val infoText: TextView = view.findViewById(R.id.recording_info_text) + private val playButton: MaterialButton = view.findViewById(R.id.play_button) + private val deleteButton: MaterialButton = view.findViewById(R.id.delete_button) + override fun bind(item: RecordingListItem) { + val entry = item as RecordingListItem.Entry + nameText.text = entry.recording.name + infoText.text = formatInfo(entry.recording) + + playButton.setOnClickListener { + val currentPosition = layoutPosition + if (currentPosition != RecyclerView.NO_POSITION) { + onPlaybackRequested(this, currentPosition) + } + } + + deleteButton.setOnClickListener { + val currentPosition = layoutPosition + if (currentPosition != RecyclerView.NO_POSITION) { + onDeleteRequested(currentPosition) + } + } + } + + fun updatePlayButton(isPlaying: Boolean) { + val iconRes = if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow + playButton.setIconResource(iconRes) + } + } + + internal sealed class RecordingListItem { + data class Header(val title: String) : RecordingListItem() + data class Entry(val recording: RecordingItem) : RecordingListItem() + } + + fun interface PlaybackSessionFactory { + fun create(): PlaybackSession + } + + interface PlaybackSession { + val isPlaying: Boolean + @Throws(IOException::class) + fun prepare(uri: Uri) + fun start() + fun pause() + fun release() + fun setOnCompletionListener(onComplete: () -> Unit) + } + + private class MediaPlayerPlaybackSession(private val context: Context) : PlaybackSession { + private val mediaPlayer = android.media.MediaPlayer() + + override val isPlaying: Boolean + get() = mediaPlayer.isPlaying + + @Throws(IOException::class) + override fun prepare(uri: Uri) { + mediaPlayer.reset() + mediaPlayer.setDataSource(context, uri) + mediaPlayer.prepare() + } + + override fun start() { + mediaPlayer.start() + } + + override fun pause() { + mediaPlayer.pause() + } + + override fun release() { + mediaPlayer.reset() + mediaPlayer.release() + } + + override fun setOnCompletionListener(onComplete: () -> Unit) { + mediaPlayer.setOnCompletionListener { + onComplete() + } + } + } + + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_RECORDING = 1 + } +} diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/SaidIt.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/SaidIt.kt new file mode 100644 index 00000000..604d4578 --- /dev/null +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/SaidIt.kt @@ -0,0 +1,15 @@ +package eu.mrogalski.saidit + +object SaidIt { + const val PACKAGE_NAME: String = "eu.mrogalski.saidit" + const val AUDIO_MEMORY_ENABLED_KEY: String = "audio_memory_enabled" + const val AUDIO_MEMORY_SIZE_KEY: String = "audio_memory_size" + const val SAMPLE_RATE_KEY: String = "sample_rate" + const val SKU: String = "unlimited_history" + const val BASE64_KEY: String = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlD0FMFGp4AWzjW" + + "LTsUZgm0soga0mVVNGFj0qoATaoQCE/LamF7yrMCIFm9sEOB1guCEhzdr16sjysrVc2EPRisS83FoJ4K0R8" + + "XPDP2TrVT2SAeQpTCG27NNH+W86SlGEqQeQhMPMhR+HDTckHv3KBpD8BZEEIbkXPv6SGFqcZub6xzn9r14l" + + "6ptYIWboKGGBh1i9/nJpdhCMPxuLn/WZnRXGxqGpfNw2xT25/muUDZgRVezy6/5eI+ciMn5H1U0ADBjXvl1" + + "Py+4ClkR1V1Mfo9lvauB03zM8Fsa3LlIPle5a+wGKsRCLW/rJ/eE/rje6X7x/n+w8J4OiFvVATj0T8QIDAQ" + + "AB" +} diff --git a/SaidIt/src/main/res/layout/activity_how_to.xml b/SaidIt/src/main/res/layout/activity_how_to.xml index 7f813235..5e2c7662 100644 --- a/SaidIt/src/main/res/layout/activity_how_to.xml +++ b/SaidIt/src/main/res/layout/activity_how_to.xml @@ -10,7 +10,7 @@ android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" app:navigationIcon="@drawable/ic_arrow_back" - app:title="How To Use" /> + app:title="@string/how_to_guide" /> + android:textAppearance="?attr/textAppearanceHeadline6" /> + + diff --git a/SaidIt/src/main/res/values/strings.xml b/SaidIt/src/main/res/values/strings.xml index 512293e5..7f08017d 100644 --- a/SaidIt/src/main/res/values/strings.xml +++ b/SaidIt/src/main/res/values/strings.xml @@ -161,6 +161,12 @@ Dismiss Save Saved Recordings + Today + Yesterday + %1$s | %2$s + Delete Recording + Are you sure you want to permanently delete this file? + Delete Error Failed to save the recording. Please try again. @@ -173,6 +179,7 @@ When you want to save something, tap the \'Save Clip\' button. You can choose how much of the recent history to save to a permanent file. Managing Recordings You can access, play, and delete your saved recordings from the \'Saved Recordings\' screen. + Step %1$d Got It diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/HowToActivityTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/HowToActivityTest.kt new file mode 100644 index 00000000..7aed6a74 --- /dev/null +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/HowToActivityTest.kt @@ -0,0 +1,52 @@ +package eu.mrogalski.saidit + +import android.widget.FrameLayout +import android.widget.TextView +import com.google.android.material.tabs.TabLayout +import com.siya.epistemophile.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HowToActivityTest { + + @Test + fun `tab layout shows sequential steps`() { + val controller = Robolectric.buildActivity(HowToActivity::class.java).setup() + val activity = controller.get() + + val tabLayout = activity.findViewById(R.id.tab_layout) + assertNotNull(tabLayout) + + HowToStep.entries.forEachIndexed { index, _ -> + val expectedTitle = activity.getString(R.string.how_to_step_tab_title, index + 1) + assertEquals(expectedTitle, tabLayout.getTabAt(index)?.text) + } + } + + @Test + fun `fragments bind title and description`() { + val controller = Robolectric.buildActivity(HowToActivity::class.java).setup() + val activity = controller.get() + + HowToStep.entries.forEachIndexed { index, step -> + val fragment = HowToPageFragment.newInstance(index) + fragment.onCreate(null) + val parent = FrameLayout(activity) + val view = fragment.onCreateView(activity.layoutInflater, parent, null)!! + fragment.onViewCreated(view, null) + + val titleView = view.findViewById(R.id.how_to_title_text) + val descriptionView = view.findViewById(R.id.how_to_description_text) + + assertEquals(activity.getString(step.titleRes), titleView.text.toString()) + assertEquals(activity.getString(step.descriptionRes), descriptionView.text.toString()) + } + } +} diff --git a/SaidIt/src/test/kotlin/eu/mrogalski/saidit/RecordingsAdapterTest.kt b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/RecordingsAdapterTest.kt new file mode 100644 index 00000000..07a1b02d --- /dev/null +++ b/SaidIt/src/test/kotlin/eu/mrogalski/saidit/RecordingsAdapterTest.kt @@ -0,0 +1,181 @@ +package eu.mrogalski.saidit + +import android.content.Context +import android.net.Uri +import android.widget.FrameLayout +import androidx.test.core.app.ApplicationProvider +import com.google.android.material.button.MaterialButton +import com.siya.epistemophile.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowAlertDialog +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class RecordingsAdapterTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val fixedNowMillis = 1705320000000L // 2024-01-15T12:00:00Z + + @Test + fun `records are grouped under appropriate headers`() { + val recordings = listOf( + RecordingItem(Uri.parse("content://today"), "Today", fixedNowMillis / 1000, 5000), + RecordingItem( + Uri.parse("content://yesterday"), + "Yesterday", + (fixedNowMillis - TimeUnit.DAYS.toMillis(1)) / 1000, + 7000 + ), + RecordingItem( + Uri.parse("content://older"), + "Older", + (fixedNowMillis - TimeUnit.DAYS.toMillis(2)) / 1000, + 9000 + ) + ) + + val adapter = RecordingsAdapter( + context, + recordings, + playbackSessionFactory = RecordingsAdapter.PlaybackSessionFactory { FakePlaybackSession() }, + nowProvider = { fixedNowMillis } + ) + + val labels = adapter.snapshotLabels() + val olderHeader = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) + .format(Date(fixedNowMillis - TimeUnit.DAYS.toMillis(2))) + assertEquals( + listOf( + "H:${context.getString(R.string.recordings_header_today)}", + "R:Today", + "H:${context.getString(R.string.recordings_header_yesterday)}", + "R:Yesterday", + "H:$olderHeader", + "R:Older" + ), + labels + ) + } + + @Test + fun `deleting recording removes orphaned header`() { + val uri = Uri.parse("content://single") + val deletedUris = mutableListOf() + val recordings = listOf( + RecordingItem(uri, "Clip", fixedNowMillis / 1000, 3000) + ) + + val adapter = RecordingsAdapter( + context, + recordings, + playbackSessionFactory = RecordingsAdapter.PlaybackSessionFactory { FakePlaybackSession() }, + nowProvider = { fixedNowMillis }, + deleteRecording = { target -> + deletedUris += target + true + } + ) + + val parent = FrameLayout(context) + val viewType = adapter.getItemViewType(1) + val holder = adapter.onCreateViewHolder(parent, viewType) + adapter.onBindViewHolder(holder, 1) + + val deleteButton = holder.itemView.findViewById(R.id.delete_button) + deleteButton.performClick() + + val dialog = checkNotNull(ShadowAlertDialog.getLatestAlertDialog()) + dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).performClick() + + assertEquals(listOf(uri), deletedUris) + assertTrue(adapter.snapshotLabels().isEmpty()) + assertEquals(0, adapter.itemCount) + } + + @Test + fun `playback toggles between play and pause`() { + val uri = Uri.parse("content://play") + val recordings = listOf( + RecordingItem(uri, "Clip", fixedNowMillis / 1000, 6000) + ) + val fakeSession = FakePlaybackSession() + + val adapter = RecordingsAdapter( + context, + recordings, + playbackSessionFactory = RecordingsAdapter.PlaybackSessionFactory { fakeSession }, + nowProvider = { fixedNowMillis } + ) + + val parent = FrameLayout(context) + val viewType = adapter.getItemViewType(1) + val holder = adapter.onCreateViewHolder(parent, viewType) + adapter.onBindViewHolder(holder, 1) + + val playButton = holder.itemView.findViewById(R.id.play_button) + playButton.performClick() + + assertTrue(fakeSession.started) + assertTrue(fakeSession.isPlaying) + + playButton.performClick() + assertTrue(fakeSession.paused) + assertFalse(fakeSession.isPlaying) + + fakeSession.complete() + playButton.performClick() + assertEquals(2, fakeSession.startCount) + } + + private class FakePlaybackSession : RecordingsAdapter.PlaybackSession { + private var completion: (() -> Unit)? = null + private var playing = false + var startCount = 0 + private set + var started = false + private set + var paused = false + private set + + override val isPlaying: Boolean + get() = playing + + override fun prepare(uri: Uri) { + // no-op + } + + override fun start() { + playing = true + started = true + startCount++ + } + + override fun pause() { + playing = false + paused = true + } + + override fun release() { + playing = false + } + + override fun setOnCompletionListener(onComplete: () -> Unit) { + completion = onComplete + } + + fun complete() { + completion?.invoke() + } + } + +} diff --git a/audio/build.gradle.kts b/audio/build.gradle.kts index 51c40ade..6c3b9ce7 100644 --- a/audio/build.gradle.kts +++ b/audio/build.gradle.kts @@ -5,11 +5,11 @@ plugins { android { namespace = "com.siya.epistemophile.audio" - compileSdk = 33 + compileSdk = 34 defaultConfig { minSdk = 21 - targetSdk = 33 + targetSdk = 34 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") @@ -25,20 +25,16 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { jvmTarget = "17" } } dependencies { - implementation("androidx.core:core-ktx:1.9.0") - implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.google.android.material:material:1.8.0") - testImplementation("junit:junit:4.13.2") - testImplementation("io.mockk:mockk:1.13.13") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + implementation(libs.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.mockito.kotlin) } diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/Complex.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/Complex.kt new file mode 100644 index 00000000..cbba2d32 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/Complex.kt @@ -0,0 +1,3 @@ +package com.siya.epistemophile.audio.dsp + +data class Complex(val real: Double, val imaginary: Double) diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVector.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVector.kt new file mode 100644 index 00000000..f88271cf --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVector.kt @@ -0,0 +1,12 @@ +package com.siya.epistemophile.audio.dsp + +class DoubleVector(val data: DoubleArray) { + + init { + require(data.isNotEmpty()) { "Data cannot be empty." } + } + + fun size(): Int = data.size + + override fun toString(): String = data.joinToString(prefix = "[", postfix = "]") +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorFrameSource.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorFrameSource.kt new file mode 100644 index 00000000..069210a3 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorFrameSource.kt @@ -0,0 +1,53 @@ +package com.siya.epistemophile.audio.dsp + +import com.siya.epistemophile.audio.pcm.PcmMonoInputStream + +class DoubleVectorFrameSource private constructor( + private val inputStream: PcmMonoInputStream, + private val frameSize: Int, + private val shiftAmount: Int, + private val paddingApplied: Boolean +) { + + fun getIterableFrameReader(): Iterable = Iterable { + NormalizedFrameIterator(inputStream, frameSize, shiftAmount, paddingApplied) + } + + fun getNormalizedFrameIterator(): Iterator { + return NormalizedFrameIterator(inputStream, frameSize, shiftAmount, paddingApplied) + } + + companion object { + fun fromSampleAmount( + inputStream: PcmMonoInputStream, + frameSize: Int, + shiftAmount: Int + ): DoubleVectorFrameSource = DoubleVectorFrameSource(inputStream, frameSize, shiftAmount, false) + + fun fromSampleAmountWithPadding( + inputStream: PcmMonoInputStream, + frameSize: Int, + shiftAmount: Int + ): DoubleVectorFrameSource = DoubleVectorFrameSource(inputStream, frameSize, shiftAmount, true) + + fun fromSizeInMilliseconds( + inputStream: PcmMonoInputStream, + frameSizeInMillis: Double, + shiftAmountInMillis: Double + ): DoubleVectorFrameSource { + val frameSize = inputStream.format.sampleCountForMilliseconds(frameSizeInMillis) + val shift = inputStream.format.sampleCountForMilliseconds(shiftAmountInMillis) + return DoubleVectorFrameSource(inputStream, frameSize, shift, false) + } + + fun fromSizeInMillisecondsWithPadding( + inputStream: PcmMonoInputStream, + frameSizeInMillis: Double, + shiftAmountInMillis: Double + ): DoubleVectorFrameSource { + val frameSize = inputStream.format.sampleCountForMilliseconds(frameSizeInMillis) + val shift = inputStream.format.sampleCountForMilliseconds(shiftAmountInMillis) + return DoubleVectorFrameSource(inputStream, frameSize, shift, true) + } + } +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorProcessingPipeline.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorProcessingPipeline.kt new file mode 100644 index 00000000..fee6e66c --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorProcessingPipeline.kt @@ -0,0 +1,17 @@ +package com.siya.epistemophile.audio.dsp + +class DoubleVectorProcessingPipeline( + private val vectorSource: Iterator, + private val processors: List +) : Iterator { + + override fun hasNext(): Boolean = vectorSource.hasNext() + + override fun next(): DoubleVector { + var vector = vectorSource.next() + processors.forEach { processor -> + vector = processor.process(vector) + } + return vector + } +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorProcessor.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorProcessor.kt new file mode 100644 index 00000000..c047e62c --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/DoubleVectorProcessor.kt @@ -0,0 +1,7 @@ +package com.siya.epistemophile.audio.dsp + +interface DoubleVectorProcessor { + fun process(input: DoubleVector): DoubleVector + + fun processInPlace(input: DoubleVector) +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/MutableComplex.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/MutableComplex.kt new file mode 100644 index 00000000..e33aa2dd --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/MutableComplex.kt @@ -0,0 +1,8 @@ +package com.siya.epistemophile.audio.dsp + +class MutableComplex(var real: Double, var imaginary: Double) { + + constructor(complex: Complex) : this(complex.real, complex.imaginary) + + fun toImmutable(): Complex = Complex(real, imaginary) +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/NormalizedFrameIterator.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/NormalizedFrameIterator.kt new file mode 100644 index 00000000..ff05c6a4 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/NormalizedFrameIterator.kt @@ -0,0 +1,70 @@ +package com.siya.epistemophile.audio.dsp + +import com.siya.epistemophile.audio.pcm.PcmMonoInputStream +import java.io.IOException + +class NormalizedFrameIterator( + private val inputStream: PcmMonoInputStream, + private val frameSize: Int, + private val shiftAmount: Int, + private val applyPadding: Boolean +) : Iterator { + + private var currentFrame: DoubleVector? = null + private var frameCounter = 0 + + init { + require(frameSize > 0) { "Frame size must be larger than zero." } + require(shiftAmount > 0) { "Shift size must be larger than zero." } + } + + override fun hasNext(): Boolean { + val data: DoubleArray = try { + if (frameCounter == 0) { + val first = inputStream.readSamplesNormalized(frameSize) + if (first.size < frameSize) { + if (applyPadding && first.isNotEmpty()) { + padFrame(first, frameSize) + } else { + return false + } + } else { + first + } + } else { + val next = inputStream.readSamplesNormalized(shiftAmount) + if (next.size < shiftAmount) { + if (applyPadding && next.isNotEmpty()) { + next + DoubleArray(shiftAmount - next.size) + } else { + return false + } + } else { + next + } + } + } catch (e: IOException) { + return false + } + + currentFrame = if (frameCounter == 0) { + DoubleVector(data) + } else { + val previous = currentFrame!!.data.clone() + System.arraycopy(data, 0, previous, previous.size - shiftAmount, shiftAmount) + DoubleVector(previous) + } + frameCounter++ + return true + } + + override fun next(): DoubleVector { + return currentFrame ?: throw NoSuchElementException() + } + + private fun padFrame(data: DoubleArray, targetSize: Int): DoubleArray { + val result = DoubleArray(targetSize) + System.arraycopy(data, 0, result, 0, data.size) + return result + } +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/WindowerFactory.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/WindowerFactory.kt new file mode 100644 index 00000000..b15a8d6e --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/dsp/WindowerFactory.kt @@ -0,0 +1,40 @@ +package com.siya.epistemophile.audio.dsp + +import kotlin.math.PI +import kotlin.math.cos + +object WindowerFactory { + + private class RaisedCosineWindower(private val alpha: Double, length: Int) : DoubleVectorProcessor { + private val cosineWindow: DoubleArray = DoubleArray(length).also { window -> + require(length > 0) { "Window length cannot be smaller than 1" } + for (i in window.indices) { + window[i] = if (length == 1) { + 1.0 + } else { + (1 - alpha) - alpha * cos(2 * PI * i / (length - 1.0)) + } + } + } + + override fun process(input: DoubleVector): DoubleVector { + val result = DoubleArray(input.data.size) + for (i in input.data.indices) { + result[i] = input.data[i] * cosineWindow[i] + } + return DoubleVector(result) + } + + override fun processInPlace(input: DoubleVector) { + for (i in input.data.indices) { + input.data[i] *= cosineWindow[i] + } + } + } + + fun newHammingWindower(length: Int): DoubleVectorProcessor = RaisedCosineWindower(0.46, length) + + fun newHanningWindower(length: Int): DoubleVectorProcessor = RaisedCosineWindower(0.5, length) + + fun newTriangularWindower(length: Int): DoubleVectorProcessor = RaisedCosineWindower(0.0, length) +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/MonoWavFileReader.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/MonoWavFileReader.kt new file mode 100644 index 00000000..29c09bdf --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/MonoWavFileReader.kt @@ -0,0 +1,48 @@ +package com.siya.epistemophile.audio.pcm + +import java.io.File +import java.io.FileInputStream +import java.io.IOException + +class MonoWavFileReader(private val file: File) { + + private val riffHeaderData = RiffHeaderData(file) + + init { + require(riffHeaderData.format.channels == 1) { "Wav file is not Mono." } + } + + @Throws(IOException::class) + fun newStream(): PcmMonoInputStream { + val stream = PcmMonoInputStream(riffHeaderData.format, FileInputStream(file)) + val skipped = stream.skip(RiffHeaderData.PCM_RIFF_HEADER_SIZE) + require(skipped >= RiffHeaderData.PCM_RIFF_HEADER_SIZE) { + "cannot skip necessary amount of bytes from underlying stream." + } + return stream + } + + @Throws(IOException::class) + fun getAllSamples(): IntArray { + return newStream().use { it.readAll() } + } + + @Throws(IOException::class) + fun getSamplesAsInts(frameStart: Int, frameEnd: Int): IntArray { + require(frameStart >= 0) { "Start Frame cannot be negative:$frameStart" } + require(frameEnd >= frameStart) { "Start Frame cannot be after end frame. Start:$frameStart, end:$frameEnd" } + require(frameEnd <= riffHeaderData.sampleCount) { + "Frame count out of bounds. Max sample count:${riffHeaderData.sampleCount} but frame is:$frameEnd" + } + return newStream().use { stream -> + stream.skipSamples(frameStart) + stream.readSamplesAsIntArray(frameEnd - frameStart) + } + } + + fun getFormat(): PcmAudioFormat = riffHeaderData.format + + fun getSampleCount(): Int = riffHeaderData.sampleCount + + fun getFile(): File = file +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmAudioFormat.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmAudioFormat.kt new file mode 100644 index 00000000..88959c21 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmAudioFormat.kt @@ -0,0 +1,54 @@ +package com.siya.epistemophile.audio.pcm + +open class PcmAudioFormat( + val sampleRate: Int, + val sampleSizeInBits: Int, + val channels: Int, + val bigEndian: Boolean, + val signed: Boolean +) { + + val bytesRequiredPerSample: Int = if (sampleSizeInBits % 8 == 0) { + sampleSizeInBits / 8 + } else { + sampleSizeInBits / 8 + 1 + } + + init { + require(sampleRate > 0) { "sampleRate cannot be less than one. But it is:$sampleRate" } + require(sampleSizeInBits in 2..31) { + "sampleSizeInBits must be between (including) 2-31. But it is:$sampleSizeInBits" + } + require(channels in 1..2) { "channels must be 1 or 2. But it is:$channels" } + } + + fun sampleCountForMilliseconds(milliseconds: Double): Int { + return (sampleRate * milliseconds / 1000.0).toInt() + } + + override fun toString(): String { + return "[ Sample Rate:$sampleRate , SampleSizeInBits:$sampleSizeInBits, channels:$channels, signed:$signed, bigEndian:$bigEndian ]" + } + + class Builder(private val sampleRate: Int) { + private var sampleSizeInBits: Int = 16 + private var channels: Int = 1 + private var bigEndian: Boolean = false + private var signed: Boolean = true + + fun channels(channels: Int) = apply { this.channels = channels } + + fun bigEndian() = apply { this.bigEndian = true } + + fun unsigned() = apply { this.signed = false } + + fun sampleSizeInBits(bits: Int) = apply { this.sampleSizeInBits = bits } + + fun build(): PcmAudioFormat = PcmAudioFormat(sampleRate, sampleSizeInBits, channels, bigEndian, signed) + } + + companion object { + fun mono16BitSignedLittleEndian(sampleRate: Int): PcmAudioFormat = + PcmAudioFormat(sampleRate, 16, 1, bigEndian = false, signed = true) + } +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmAudioHelper.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmAudioHelper.kt new file mode 100644 index 00000000..8c29ce0d --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmAudioHelper.kt @@ -0,0 +1,59 @@ +package com.siya.epistemophile.audio.pcm + +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile + +object PcmAudioHelper { + + @Throws(IOException::class) + fun convertRawToWav(format: WavAudioFormat, rawSource: File, wavTarget: File) { + DataOutputStream(FileOutputStream(wavTarget)).use { dos -> + dos.write(RiffHeaderData(format, 0).asByteArray()) + DataInputStream(FileInputStream(rawSource)).use { dis -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var read: Int + var total = 0 + while (dis.read(buffer).also { read = it } != -1) { + total += read + dos.write(buffer, 0, read) + } + modifyRiffSizeData(wavTarget, total) + } + } + } + + @Throws(IOException::class) + fun convertWavToRaw(wavSource: File, rawTarget: File) { + PcmByteUtils.copy(MonoWavFileReader(wavSource).newStream(), FileOutputStream(rawTarget)) + } + + @Throws(IOException::class) + fun readAllFromWavNormalized(fileName: String): DoubleArray { + return MonoWavFileReader(File(fileName)).newStream().use { it.readSamplesNormalized() } + } + + @Throws(IOException::class) + fun modifyRiffSizeData(wavFile: File, size: Int) { + RandomAccessFile(wavFile, "rw").use { raf -> + raf.seek(RiffHeaderData.RIFF_CHUNK_SIZE_INDEX.toLong()) + raf.write(PcmByteUtils.toByteArray(size + 36, bigEndian = false)) + raf.seek(RiffHeaderData.RIFF_SUBCHUNK2_SIZE_INDEX.toLong()) + raf.write(PcmByteUtils.toByteArray(size, bigEndian = false)) + } + } + + @Throws(IOException::class) + fun generateSilenceWavFile(wavAudioFormat: WavAudioFormat, file: File, seconds: Double) { + WavFileWriter(wavAudioFormat, file).use { writer -> + val empty = IntArray((seconds * wavAudioFormat.sampleRate).toInt()) + writer.write(empty) + } + } + + private const val DEFAULT_BUFFER_SIZE = 4096 +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmByteUtils.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmByteUtils.kt new file mode 100644 index 00000000..0b47bd15 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmByteUtils.kt @@ -0,0 +1,136 @@ +package com.siya.epistemophile.audio.pcm + +import java.io.Closeable +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import kotlin.math.min + +internal object PcmByteUtils { + + fun toReducedBitIntArray( + data: ByteArray, + length: Int, + bytesPerSample: Int, + sampleSizeInBits: Int, + bigEndian: Boolean + ): IntArray { + if (length == 0) return IntArray(0) + require(length % bytesPerSample == 0) { + "Byte count $length is not aligned to sample size $bytesPerSample" + } + val sampleCount = length / bytesPerSample + val result = IntArray(sampleCount) + val shift = 32 - sampleSizeInBits + var offset = 0 + repeat(sampleCount) { index -> + var value = 0 + if (bigEndian) { + repeat(bytesPerSample) { step -> + value = (value shl 8) or (data[offset + step].toInt() and 0xFF) + } + } else { + for (step in bytesPerSample - 1 downTo 0) { + value = (value shl 8) or (data[offset + step].toInt() and 0xFF) + } + } + result[index] = (value shl shift) shr shift + offset += bytesPerSample + } + return result + } + + fun toByteArray( + values: IntArray, + length: Int, + bytesPerSample: Int, + bigEndian: Boolean + ): ByteArray { + val actualLength = min(length, values.size) + val result = ByteArray(actualLength * bytesPerSample) + var offset = 0 + repeat(actualLength) { index -> + val value = values[index] + if (bigEndian) { + for (step in bytesPerSample - 1 downTo 0) { + result[offset + step] = (value shr ((bytesPerSample - 1 - step) * 8)).toByte() + } + } else { + repeat(bytesPerSample) { step -> + result[offset + step] = (value shr (step * 8)).toByte() + } + } + offset += bytesPerSample + } + return result + } + + fun toByteArray(values: ShortArray, length: Int, bigEndian: Boolean): ByteArray { + val actualLength = min(length, values.size) + val result = ByteArray(actualLength * 2) + var offset = 0 + repeat(actualLength) { index -> + val value = values[index].toInt() + if (bigEndian) { + result[offset] = (value shr 8).toByte() + result[offset + 1] = value.toByte() + } else { + result[offset] = value.toByte() + result[offset + 1] = (value shr 8).toByte() + } + offset += 2 + } + return result + } + + fun toByteArray(value: Int, bigEndian: Boolean): ByteArray { + val bytes = ByteArray(4) + if (bigEndian) { + bytes[0] = (value shr 24).toByte() + bytes[1] = (value shr 16).toByte() + bytes[2] = (value shr 8).toByte() + bytes[3] = value.toByte() + } else { + bytes[0] = value.toByte() + bytes[1] = (value shr 8).toByte() + bytes[2] = (value shr 16).toByte() + bytes[3] = (value shr 24).toByte() + } + return bytes + } + + fun toByteArray(value: Short, bigEndian: Boolean): ByteArray { + val bytes = ByteArray(2) + if (bigEndian) { + bytes[0] = (value.toInt() shr 8).toByte() + bytes[1] = value.toByte() + } else { + bytes[0] = value.toByte() + bytes[1] = (value.toInt() shr 8).toByte() + } + return bytes + } + + fun readAll(input: InputStream): ByteArray = input.readBytes() + + fun copy(input: InputStream, output: OutputStream) { + input.use { source -> + output.use { target -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = source.read(buffer) + if (read == -1) break + target.write(buffer, 0, read) + } + } + } + } + + fun closeQuietly(closeable: Closeable?) { + try { + closeable?.close() + } catch (_: IOException) { + // Ignore + } + } +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmMonoInputStream.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmMonoInputStream.kt new file mode 100644 index 00000000..804905c7 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmMonoInputStream.kt @@ -0,0 +1,115 @@ +package com.siya.epistemophile.audio.pcm + +import java.io.DataInputStream +import java.io.IOException +import java.io.InputStream + +class PcmMonoInputStream( + val format: PcmAudioFormat, + inputStream: InputStream +) : InputStream() { + + private val dataInput = DataInputStream(inputStream) + private val maxPositiveIntegerForSampleSize: Int = 0x7fffffff ushr (32 - format.sampleSizeInBits) + + init { + require(format.channels == 1) { "Only mono streams are supported." } + } + + override fun read(): Int = dataInput.read() + + @Throws(IOException::class) + fun readSamplesAsIntArray(amount: Int): IntArray { + val buffer = ByteArray(amount * format.bytesRequiredPerSample) + val readAmount = dataInput.read(buffer) + if (readAmount == -1) { + return IntArray(0) + } + return PcmByteUtils.toReducedBitIntArray( + buffer, + readAmount, + format.bytesRequiredPerSample, + format.sampleSizeInBits, + format.bigEndian + ) + } + + @Throws(IOException::class) + fun readAll(): IntArray { + val all = PcmByteUtils.readAll(dataInput) + return PcmByteUtils.toReducedBitIntArray( + all, + all.size, + format.bytesRequiredPerSample, + format.sampleSizeInBits, + format.bigEndian + ) + } + + @Throws(IOException::class) + fun readSamplesAsByteArray(amount: Int): ByteArray { + val buffer = ByteArray(amount * format.bytesRequiredPerSample) + val readCount = dataInput.read(buffer) + if (readCount == -1) { + return ByteArray(0) + } + if (readCount != buffer.size) { + validateReadCount(readCount) + return buffer.copyOf(readCount) + } + return buffer + } + + private fun validateReadCount(readCount: Int) { + require(readCount % format.bytesRequiredPerSample == 0) { + "unexpected amounts of bytes read from the input stream. Byte count must be an order of:${format.bytesRequiredPerSample}" + } + } + + @Throws(IOException::class) + fun readSamplesAsIntArray(frameStart: Int, frameEnd: Int): IntArray { + skipSamples(frameStart) + return readSamplesAsIntArray(frameEnd - frameStart) + } + + @Throws(IOException::class) + fun skipSamples(skipAmount: Int): Int { + val actualSkipped = dataInput.skipBytes(skipAmount * format.bytesRequiredPerSample) + return actualSkipped / format.bytesRequiredPerSample + } + + @Throws(IOException::class) + fun readSamplesNormalized(amount: Int): DoubleArray = normalize(readSamplesAsIntArray(amount)) + + @Throws(IOException::class) + fun readSamplesNormalized(): DoubleArray = normalize(readAll()) + + private fun normalize(original: IntArray): DoubleArray { + if (original.isEmpty()) return DoubleArray(0) + val normalized = DoubleArray(original.size) + for (i in original.indices) { + normalized[i] = original[i].toDouble() / maxPositiveIntegerForSampleSize + } + return normalized + } + + override fun close() { + PcmByteUtils.closeQuietly(dataInput) + } + + fun calculateSampleByteIndex(second: Double): Int { + require(second >= 0) { "Time information cannot be negative." } + var location = (second * format.sampleRate * format.bytesRequiredPerSample).toInt() + if (location % format.bytesRequiredPerSample != 0) { + location += format.bytesRequiredPerSample - location % format.bytesRequiredPerSample + } + return location + } + + fun calculateSampleTime(sampleIndex: Int): Double { + require(sampleIndex >= 0) { "sampleIndex information cannot be negative:$sampleIndex" } + return sampleIndex.toDouble() / format.sampleRate + } + + fun skip(byteCount: Int): Long = dataInput.skipBytes(byteCount).toLong() +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmMonoOutputStream.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmMonoOutputStream.kt new file mode 100644 index 00000000..7cc5c667 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/PcmMonoOutputStream.kt @@ -0,0 +1,44 @@ +package com.siya.epistemophile.audio.pcm + +import java.io.DataOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.OutputStream + +class PcmMonoOutputStream( + private val format: PcmAudioFormat, + private val dataOutput: DataOutputStream +) : OutputStream() { + + constructor(format: PcmAudioFormat, file: File) : this( + format, + DataOutputStream(FileOutputStream(file)) + ) + + @Throws(IOException::class) + override fun write(b: Int) { + dataOutput.write(b) + } + + @Throws(IOException::class) + override fun write(buffer: ByteArray, offset: Int, count: Int) { + dataOutput.write(buffer, offset, count) + } + + @Throws(IOException::class) + fun write(values: ShortArray) { + val bytes = PcmByteUtils.toByteArray(values, values.size, format.bigEndian) + dataOutput.write(bytes) + } + + @Throws(IOException::class) + fun write(values: IntArray) { + val bytes = PcmByteUtils.toByteArray(values, values.size, format.bytesRequiredPerSample, format.bigEndian) + dataOutput.write(bytes) + } + + override fun close() { + PcmByteUtils.closeQuietly(dataOutput) + } +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/RiffHeaderData.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/RiffHeaderData.kt new file mode 100644 index 00000000..c999842b --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/RiffHeaderData.kt @@ -0,0 +1,112 @@ +package com.siya.epistemophile.audio.pcm + +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.File +import java.io.FileInputStream +import java.io.IOException + +internal class RiffHeaderData( + val format: PcmAudioFormat, + val totalSamplesInByte: Int +) { + + val sampleCount: Int + get() = totalSamplesInByte / format.bytesRequiredPerSample + + fun timeSeconds(): Double { + return totalSamplesInByte.toDouble() / format.bytesRequiredPerSample / format.sampleRate + } + + constructor(pair: Pair) : this(pair.first, pair.second) + + constructor(inputStream: DataInputStream) : this(readHeader(inputStream)) + + constructor(file: File) : this(DataInputStream(FileInputStream(file))) + + fun asByteArray(): ByteArray { + val output = ByteArrayOutputStream() + return try { + output.write(PcmByteUtils.toByteArray(RIFF_CHUNK_ID, bigEndian = true)) + output.write(PcmByteUtils.toByteArray(36 + totalSamplesInByte, bigEndian = false)) + output.write(PcmByteUtils.toByteArray(WAVE_FORMAT_ID, bigEndian = true)) + + output.write(PcmByteUtils.toByteArray(FMT_CHUNK_ID, bigEndian = true)) + output.write(PcmByteUtils.toByteArray(16, bigEndian = false)) + output.write(PcmByteUtils.toByteArray(PCM_AUDIO_FORMAT.toShort(), bigEndian = false)) + output.write(PcmByteUtils.toByteArray(format.channels.toShort(), bigEndian = false)) + output.write(PcmByteUtils.toByteArray(format.sampleRate, bigEndian = false)) + val byteRate = format.channels * format.sampleRate * format.bytesRequiredPerSample + output.write(PcmByteUtils.toByteArray(byteRate, bigEndian = false)) + output.write(PcmByteUtils.toByteArray((format.channels * format.bytesRequiredPerSample).toShort(), bigEndian = false)) + output.write(PcmByteUtils.toByteArray(format.sampleSizeInBits.toShort(), bigEndian = false)) + + output.write(PcmByteUtils.toByteArray(DATA_CHUNK_ID, bigEndian = true)) + output.write(PcmByteUtils.toByteArray(totalSamplesInByte, bigEndian = false)) + output.toByteArray() + } catch (io: IOException) { + ByteArray(0) + } finally { + PcmByteUtils.closeQuietly(output) + } + } + + companion object { + const val PCM_RIFF_HEADER_SIZE = 44 + const val RIFF_CHUNK_SIZE_INDEX = 4 + const val RIFF_SUBCHUNK2_SIZE_INDEX = 40 + + private const val RIFF_CHUNK_ID = 0x52494646 + private const val WAVE_FORMAT_ID = 0x57415645 + private const val FMT_CHUNK_ID = 0x666d7420 + private const val DATA_CHUNK_ID = 0x64617461 + private const val PCM_AUDIO_FORMAT = 1 + + private fun readHeader(stream: DataInputStream): Pair { + return try { + val buffer4 = ByteArray(4) + val buffer2 = ByteArray(2) + + stream.skipBytes(4 + 4 + 4 + 4 + 4 + 2) + + stream.readFully(buffer2) + val channels = littleEndianShort(buffer2) + + stream.readFully(buffer4) + val sampleRate = littleEndianInt(buffer4) + + stream.skipBytes(4 + 2) + + stream.readFully(buffer2) + val sampleSizeInBits = littleEndianShort(buffer2) + + stream.skipBytes(4) + + stream.readFully(buffer4) + val totalSamplesInByte = littleEndianInt(buffer4) + + val format = WavAudioFormat.Builder() + .channels(channels) + .sampleRate(sampleRate) + .sampleSizeInBits(sampleSizeInBits) + .build() + format to totalSamplesInByte + } finally { + PcmByteUtils.closeQuietly(stream) + } + } + + private fun littleEndianShort(bytes: ByteArray): Int { + require(bytes.size >= 2) + return (bytes[1].toInt() shl 8) or (bytes[0].toInt() and 0xFF) + } + + private fun littleEndianInt(bytes: ByteArray): Int { + require(bytes.size >= 4) + return (bytes[3].toInt() shl 24) or + ((bytes[2].toInt() and 0xFF) shl 16) or + ((bytes[1].toInt() and 0xFF) shl 8) or + (bytes[0].toInt() and 0xFF) + } + } +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/WavAudioFormat.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/WavAudioFormat.kt new file mode 100644 index 00000000..fcf69c31 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/WavAudioFormat.kt @@ -0,0 +1,35 @@ +package com.siya.epistemophile.audio.pcm + +class WavAudioFormat private constructor( + sampleRate: Int, + sampleSizeInBits: Int, + channels: Int, + signed: Boolean +) : PcmAudioFormat(sampleRate, sampleSizeInBits, channels, bigEndian = false, signed = signed) { + + class Builder { + private var sampleRate: Int = 0 + private var sampleSizeInBits: Int = 16 + private var channels: Int = 1 + + fun sampleRate(sampleRate: Int) = apply { this.sampleRate = sampleRate } + + fun channels(channels: Int) = apply { this.channels = channels } + + fun sampleSizeInBits(bits: Int) = apply { this.sampleSizeInBits = bits } + + fun build(): WavAudioFormat { + val isSigned = sampleSizeInBits != 8 + return WavAudioFormat(sampleRate, sampleSizeInBits, channels, isSigned) + } + } + + companion object { + fun mono16Bit(sampleRate: Int): WavAudioFormat = WavAudioFormat(sampleRate, 16, 1, true) + + fun wavFormat(sampleRate: Int, sampleSizeInBits: Int, channels: Int): WavAudioFormat { + val signed = sampleSizeInBits != 8 + return WavAudioFormat(sampleRate, sampleSizeInBits, channels, signed) + } + } +} diff --git a/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/WavFileWriter.kt b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/WavFileWriter.kt new file mode 100644 index 00000000..ba7912c1 --- /dev/null +++ b/audio/src/main/kotlin/com/siya/epistemophile/audio/pcm/WavFileWriter.kt @@ -0,0 +1,70 @@ +package com.siya.epistemophile.audio.pcm + +import java.io.Closeable +import java.io.File +import java.io.IOException + +class WavFileWriter( + private val wavAudioFormat: WavAudioFormat, + private val file: File +) : Closeable { + + private val outputStream = PcmMonoOutputStream(wavAudioFormat, file) + private var totalSampleBytesWritten: Int = 0 + + init { + require(!wavAudioFormat.bigEndian) { "Wav file cannot contain bigEndian sample data." } + if (wavAudioFormat.sampleSizeInBits > 8) { + require(wavAudioFormat.signed) { "Wav file cannot contain unsigned data for this sampleSize:${wavAudioFormat.sampleSizeInBits}" } + } + outputStream.write(RiffHeaderData(wavAudioFormat, 0).asByteArray()) + } + + @Throws(IOException::class) + fun write(bytes: ByteArray): WavFileWriter { + checkLimit(totalSampleBytesWritten, bytes.size) + outputStream.write(bytes, 0, bytes.size) + totalSampleBytesWritten += bytes.size + return this + } + + @Throws(IOException::class) + fun write(bytes: ByteArray, offset: Int, count: Int): WavFileWriter { + checkLimit(totalSampleBytesWritten, count) + outputStream.write(bytes, offset, count) + totalSampleBytesWritten += count + return this + } + + @Throws(IOException::class) + fun write(samples: IntArray): WavFileWriter { + val bytesToAdd = samples.size * wavAudioFormat.bytesRequiredPerSample + checkLimit(totalSampleBytesWritten, bytesToAdd) + outputStream.write(samples) + totalSampleBytesWritten += bytesToAdd + return this + } + + @Throws(IOException::class) + fun write(samples: ShortArray): WavFileWriter { + val bytesToAdd = samples.size * 2 + checkLimit(totalSampleBytesWritten, bytesToAdd) + outputStream.write(samples) + totalSampleBytesWritten += bytesToAdd + return this + } + + private fun checkLimit(total: Int, toAdd: Int) { + val result = total.toLong() + toAdd + require(result < Int.MAX_VALUE) { "Size of bytes is too big:$result" } + } + + override fun close() { + PcmByteUtils.closeQuietly(outputStream) + PcmAudioHelper.modifyRiffSizeData(file, totalSampleBytesWritten) + } + + fun getWavFormat(): PcmAudioFormat = wavAudioFormat + + fun getTotalSampleBytesWritten(): Int = totalSampleBytesWritten +} diff --git a/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt b/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt index c64666ac..7e79a821 100644 --- a/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt +++ b/audio/src/test/kotlin/com/siya/epistemophile/audio/AudioPlayerRecorderTest.kt @@ -2,10 +2,11 @@ package com.siya.epistemophile.audio import android.media.MediaPlayer import android.media.MediaRecorder -import io.mockk.confirmVerified -import io.mockk.every -import io.mockk.mockk -import io.mockk.verifyOrder +import org.mockito.kotlin.any +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoMoreInteractions import org.junit.After import org.junit.Before import org.junit.Test @@ -20,11 +21,11 @@ class AudioPlayerRecorderTest { @Before fun setUp() { outputFile = kotlin.io.path.createTempFile(prefix = "test_recording", suffix = ".mp4").toFile() - mediaRecorder = mockk(relaxed = true) - mediaPlayer = mockk(relaxed = true) + mediaRecorder = mock() + mediaPlayer = mock() - every { mediaRecorder.setOutputFile(any()) } returns Unit - every { mediaPlayer.setDataSource(any()) } returns Unit + doNothing().`when`(mediaRecorder).setOutputFile(any()) + doNothing().`when`(mediaPlayer).setDataSource(any()) } @After @@ -38,34 +39,28 @@ class AudioPlayerRecorderTest { val player = AudioPlayer(outputFile) { mediaPlayer } recorder.start() - verifyOrder { - mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC) - mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) - mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) - mediaRecorder.setOutputFile(outputFile.absolutePath) - mediaRecorder.prepare() - mediaRecorder.start() - } + val recorderOrder = inOrder(mediaRecorder) + recorderOrder.verify(mediaRecorder).setAudioSource(MediaRecorder.AudioSource.MIC) + recorderOrder.verify(mediaRecorder).setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + recorderOrder.verify(mediaRecorder).setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + recorderOrder.verify(mediaRecorder).setOutputFile(outputFile.absolutePath) + recorderOrder.verify(mediaRecorder).prepare() + recorderOrder.verify(mediaRecorder).start() recorder.stop() - verifyOrder { - mediaRecorder.stop() - mediaRecorder.release() - } - confirmVerified(mediaRecorder) + recorderOrder.verify(mediaRecorder).stop() + recorderOrder.verify(mediaRecorder).release() + verifyNoMoreInteractions(mediaRecorder) player.start() - verifyOrder { - mediaPlayer.setDataSource(outputFile.absolutePath) - mediaPlayer.prepare() - mediaPlayer.start() - } + val playerOrder = inOrder(mediaPlayer) + playerOrder.verify(mediaPlayer).setDataSource(outputFile.absolutePath) + playerOrder.verify(mediaPlayer).prepare() + playerOrder.verify(mediaPlayer).start() player.stop() - verifyOrder { - mediaPlayer.stop() - mediaPlayer.release() - } - confirmVerified(mediaPlayer) + playerOrder.verify(mediaPlayer).stop() + playerOrder.verify(mediaPlayer).release() + verifyNoMoreInteractions(mediaPlayer) } } diff --git a/audio/src/test/kotlin/com/siya/epistemophile/audio/dsp/NormalizedFrameIteratorTest.kt b/audio/src/test/kotlin/com/siya/epistemophile/audio/dsp/NormalizedFrameIteratorTest.kt new file mode 100644 index 00000000..1ed2bbc6 --- /dev/null +++ b/audio/src/test/kotlin/com/siya/epistemophile/audio/dsp/NormalizedFrameIteratorTest.kt @@ -0,0 +1,54 @@ +package com.siya.epistemophile.audio.dsp + +import com.siya.epistemophile.audio.pcm.PcmAudioFormat +import com.siya.epistemophile.audio.pcm.PcmByteUtils +import com.siya.epistemophile.audio.pcm.PcmMonoInputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream + +class NormalizedFrameIteratorTest { + + @Test + fun `iterator yields overlapping frames`() { + val format = PcmAudioFormat.mono16BitSignedLittleEndian(8000) + val samples = intArrayOf(0, 1000, 2000, 3000, 4000, 5000) + val bytes = PcmByteUtils.toByteArray(samples, samples.size, format.bytesRequiredPerSample, format.bigEndian) + val stream = PcmMonoInputStream(format, ByteArrayInputStream(bytes)) + val iterator = NormalizedFrameIterator(stream, frameSize = 4, shiftAmount = 2, applyPadding = false) + + val frames = mutableListOf() + while (iterator.hasNext()) { + frames += iterator.next().data + } + + assertEquals(2, frames.size) + val normalization = 0x7fff.toDouble() + val expectedFirst = doubleArrayOf(0.0, 1000 / normalization, 2000 / normalization, 3000 / normalization) + val expectedSecond = doubleArrayOf(0.0, 1000 / normalization, 4000 / normalization, 5000 / normalization) + frames[0].forEachIndexed { index, value -> + assertEquals(expectedFirst[index], value, 1e-3) + } + frames[1].forEachIndexed { index, value -> + assertEquals(expectedSecond[index], value, 1e-3) + } + } + + @Test + fun `iterator pads final frame when requested`() { + val format = PcmAudioFormat.mono16BitSignedLittleEndian(8000) + val samples = intArrayOf(1000, 2000, 3000) + val bytes = PcmByteUtils.toByteArray(samples, samples.size, format.bytesRequiredPerSample, format.bigEndian) + val stream = PcmMonoInputStream(format, ByteArrayInputStream(bytes)) + val iterator = NormalizedFrameIterator(stream, frameSize = 4, shiftAmount = 2, applyPadding = true) + + assertTrue(iterator.hasNext()) + val first = iterator.next().data + assertEquals(4, first.size) + assertEquals(1000 / 0x7fff.toDouble(), first[0], 1e-3) + assertEquals(2000 / 0x7fff.toDouble(), first[1], 1e-3) + assertEquals(3000 / 0x7fff.toDouble(), first[2], 1e-3) + assertEquals(0.0, first[3], 1e-6) + } +} diff --git a/audio/src/test/kotlin/com/siya/epistemophile/audio/dsp/WindowerFactoryTest.kt b/audio/src/test/kotlin/com/siya/epistemophile/audio/dsp/WindowerFactoryTest.kt new file mode 100644 index 00000000..5383642b --- /dev/null +++ b/audio/src/test/kotlin/com/siya/epistemophile/audio/dsp/WindowerFactoryTest.kt @@ -0,0 +1,47 @@ +package com.siya.epistemophile.audio.dsp + +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import kotlin.math.PI +import kotlin.math.cos + +class WindowerFactoryTest { + + @Test + fun `hamming window applies expected coefficients`() { + val processor = WindowerFactory.newHammingWindower(4) + val input = DoubleVector(DoubleArray(4) { 1.0 }) + val output = processor.process(input).data + val expected = coefficients(alpha = 0.46, length = 4) + assertArrayEquals(expected, output, 1e-6) + } + + @Test + fun `hanning window processes in place`() { + val processor = WindowerFactory.newHanningWindower(4) + val vector = DoubleVector(DoubleArray(4) { 1.0 }) + processor.processInPlace(vector) + val expected = coefficients(alpha = 0.5, length = 4) + assertArrayEquals(expected, vector.data, 1e-6) + } + + @Test + fun `triangular window returns unity when length is one`() { + val processor = WindowerFactory.newTriangularWindower(1) + val vector = DoubleVector(doubleArrayOf(2.0)) + val output = processor.process(vector) + assertArrayEquals(doubleArrayOf(2.0), output.data, 1e-6) + } + + private fun coefficients(alpha: Double, length: Int): DoubleArray { + val result = DoubleArray(length) + for (i in 0 until length) { + result[i] = if (length == 1) { + 1.0 + } else { + (1 - alpha) - alpha * cos(2 * PI * i / (length - 1.0)) + } + } + return result + } +} diff --git a/audio/src/test/kotlin/com/siya/epistemophile/audio/pcm/WavIoTest.kt b/audio/src/test/kotlin/com/siya/epistemophile/audio/pcm/WavIoTest.kt new file mode 100644 index 00000000..28c6cd68 --- /dev/null +++ b/audio/src/test/kotlin/com/siya/epistemophile/audio/pcm/WavIoTest.kt @@ -0,0 +1,67 @@ +package com.siya.epistemophile.audio.pcm + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.nio.file.Files + +class WavIoTest { + + @Test + fun `write and read wav round trip`() { + val format = WavAudioFormat.mono16Bit(44100) + val samples = intArrayOf(0, 1000, -1000, 32767, -32768) + val wavFile = Files.createTempFile("roundtrip", ".wav").toFile() + try { + WavFileWriter(format, wavFile).use { writer -> + writer.write(samples) + } + + val reader = MonoWavFileReader(wavFile) + assertEquals(samples.size, reader.getSampleCount()) + assertEquals(format.sampleRate, reader.getFormat().sampleRate) + val readSamples = reader.getAllSamples() + assertArrayEquals(samples, readSamples) + } finally { + wavFile.delete() + } + } + + @Test + fun `convert raw pcm to wav`() { + val format = WavAudioFormat.mono16Bit(22050) + val rawFile = Files.createTempFile("raw", ".pcm").toFile() + val wavFile = Files.createTempFile("converted", ".wav").toFile() + try { + val rawSamples = intArrayOf(0, 2000, -2000, 1500) + FileOutputStream(rawFile).use { output -> + output.write(PcmByteUtils.toByteArray(rawSamples, rawSamples.size, format.bytesRequiredPerSample, format.bigEndian)) + } + + PcmAudioHelper.convertRawToWav(format, rawFile, wavFile) + + val reader = MonoWavFileReader(wavFile) + assertEquals(rawSamples.size, reader.getSampleCount()) + assertArrayEquals(rawSamples, reader.getAllSamples()) + } finally { + rawFile.delete() + wavFile.delete() + } + } + + @Test + fun `normalize samples produces expected amplitude`() { + val format = PcmAudioFormat.mono16BitSignedLittleEndian(8000) + val intSamples = intArrayOf(0, 32767, -32768) + val byteArray = PcmByteUtils.toByteArray(intSamples, intSamples.size, format.bytesRequiredPerSample, format.bigEndian) + val stream = PcmMonoInputStream(format, ByteArrayInputStream(byteArray)) + val normalized = stream.readSamplesNormalized(intSamples.size) + assertEquals(3, normalized.size) + assertEquals(0.0, normalized[0], 1e-6) + assertEquals(1.0, normalized[1], 1e-3) + assertEquals(-1.0, normalized[2], 1e-3) + } +} diff --git a/docs/architecture/kotlin-migration-plan.md b/docs/architecture/kotlin-migration-plan.md index 40129431..06542c63 100644 --- a/docs/architecture/kotlin-migration-plan.md +++ b/docs/architecture/kotlin-migration-plan.md @@ -2,21 +2,19 @@ ## Current Status - All modules outside `SaidIt/` are already written in Kotlin (domain, data, core, audio, features/recorder). -- Legacy `SaidIt/` module still contains the app shell, presentation wiring, and DSP helpers in Java. +- Legacy `SaidIt/` presentation shell has been converted to Kotlin; remaining Java sources are limited to generated stubs and legacy instrumentation/unit tests. - Robolectric, JVM, and health-check tiers 0–3 pass after a clean build, so the remaining migration can proceed incrementally. ## Remaining Java Surface -- **UI Shell (`eu.mrogalski.saidit`)** – 7 activity/adapter classes (~825 LOC) drive legacy screens and navigation. -- **DSP & PCM helpers (`simplesound`)** – 16 classes (~1 050 LOC) provide audio framing and WAV support. -- **Instrumentation Tests** – 4 Robolectric/espresso suites plus 1 unit test (~250 LOC) under `src/androidTest` and `src/test`. +- **Instrumentation & Legacy Unit Tests** – 4 Robolectric/espresso suites plus 1 unit test (~250 LOC) under `src/androidTest` and `src/test`. ## Workstreams -1. **Presentation Layer Rewrite** - - Convert `SaidItActivity`, `SettingsActivity`, `RecordingsAdapter`, and onboarding UI classes to Kotlin. - - Align with new architecture (ViewModels, DI) and replace deprecated Android APIs while porting. -2. **Audio Utility Migration** - - Port `simplesound` DSP/PCM classes to Kotlin and relocate into `audio/` if practical. - - Add JVM tests that exercise framing, windowing, and WAV IO edge cases. +1. **Presentation Layer Rewrite** *(partially complete)* + - `SaidIt` onboarding flow, toolbar pager, and recordings list adapter now run in Kotlin with new Robolectric coverage for pager titles and adapter grouping behaviour. + - Remaining scope: service wiring and settings screens should adopt shared ViewModel patterns. +2. **Audio Utility Migration** *(in progress)* + - `simplesound` PCM/DSP helpers relocated to `audio` as Kotlin implementations with new JVM tests covering WAV IO, windowing coefficients, and normalized frame iteration. + - Next step: ensure downstream call sites (service save path) adopt the new APIs and remove transient compatibility shims. 3. **Instrumentation & Test Suite Refresh** - Recreate the remaining Java tests in Kotlin, expanding scenarios to cover error states, permission flows, and autosave. - Standardize on coroutines test utilities and shared `MainDispatcherRule`. @@ -30,9 +28,9 @@ 2. **ECHO-202 – Port Recording Recycler Adapter & UI Helpers** - Scope: `RecordingsAdapter`, related view holders/utilities, plus migration to Kotlin data classes. - DoD: Kotlin adapter with unit/UI tests covering empty/error states. -3. **ECHO-203 – Migrate DSP/PCM Library** - - Scope: all `simplesound` classes; optional relocation into `audio` module. - - DoD: Kotlin implementations, new unit tests for numerical accuracy and IO edge cases. +3. **ECHO-203 – Migrate DSP/PCM Library** *(actively executing)* + - Scope: all `simplesound` classes; relocation into `audio` module complete with WAV/windowing/frame iterator tests in place. + - DoD: ensure service consumers switch to the Kotlin APIs, then delete deprecated references and update documentation. 4. **ECHO-204 – Rewrite Instrumentation Suite in Kotlin** - Scope: espresso/Robolectric tests under `src/androidTest/java` and `src/test/java`. - DoD: Kotlin tests using shared rules, expanded coverage for autosave, background service, and fragment flows. @@ -46,13 +44,14 @@ - Capture migration risks and mitigations in `docs/project-state/health-dashboard.md` during the effort. ## Dependencies & Risk Notes -- DSP migration (ECHO-203) should land before adapter refactors rely on Kotlin-only audio APIs. +- DSP migration (ECHO-203) should land before adapter refactors rely on Kotlin-only audio APIs. New PCM utilities now live in `audio`; verify background service playback still compiles once wired in. - Ensure Hilt modules are updated when activities move to Kotlin to avoid classpath mismatches. +- Normalization math now reimplements what the `jcaki` JAR previously handled; added JVM tests mitigate regressions, but monitor for edge cases with unsigned 8‑bit WAVs. - Watch for behavioural drift in autosave/background service; keep instrumentation tests green during each step. ## ECHO-203 Detailed Plan - **Inventory & Ownership**: Catalogue every class under `SaidIt/src/main/java/simplesound/**` with notes on current consumers (service, tests, adapters). Confirm whether any code in other modules relies on Java-specific APIs. - **Modulo Conversion Batches**: Port helpers in logical batches (e.g., DSP math, PCM streams, WAV IO) to keep reviews tight and allow incremental verification. -- **API Surface Cleanup**: While converting, replace mutable arrays with Kotlin collections where safe, introduce inline value classes for sample frames, and document public APIs under `audio/`. -- **Test Strategy**: Add targeted property-based tests for windowing, plus golden-file tests for WAV read/write accuracy. Wire them into `audio` module JVM tests so they run with tier-2 health checks. +- **API Surface Cleanup**: Kotlin versions now expose strongly-typed helpers in `com.siya.epistemophile.audio`; continue tightening visibility (e.g., sealed list items, factory functions) as consumers adopt them. +- **Test Strategy**: Added deterministic JVM tests for Hamming/Hanning windows, WAV round trips, and normalized frame iteration. Consider property-based expansions (edge sample sizes) in follow-up tickets. - **Adoption Steps**: Once Kotlin utilities land, update repositories/adapters to consume the new APIs, then delete the legacy Java package and update documentation. From e1c0343f6303b1905f77993b3cd2c6aad70b74ae Mon Sep 17 00:00:00 2001 From: Siyabonga Buthelezi <114085572+ElliotBadinger@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:33:25 +0200 Subject: [PATCH 2/2] Agent Session 2025-10-06: Preserve playback session on unrelated deletions --- SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt index b0629440..ae653358 100644 --- a/SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt @@ -148,8 +148,6 @@ class RecordingsAdapter @JvmOverloads constructor( playingPosition = null } - playbackSession = null - items.removeAt(position) notifyItemRemoved(position) adjustPlayingPositionAfterRemoval(position)