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 0857e11f..00000000 Binary files a/SaidIt/libs/jcaki-1.0-Alpha.jar and /dev/null differ 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..ae653358 --- /dev/null +++ b/SaidIt/src/main/kotlin/eu/mrogalski/saidit/RecordingsAdapter.kt @@ -0,0 +1,334 @@ +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 + } + + 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.