diff --git a/src/components/CloseButton.tsx b/src/components/CloseButton.tsx index cc2b6c8..4556204 100644 --- a/src/components/CloseButton.tsx +++ b/src/components/CloseButton.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { useTranslation } from "@/configHooks" import { HeaderButton } from "@react-navigation/elements" import { useNavigation } from "@react-navigation/native" diff --git a/src/components/ConfigButton.tsx b/src/components/ConfigButton.tsx index 3377c45..79af937 100644 --- a/src/components/ConfigButton.tsx +++ b/src/components/ConfigButton.tsx @@ -1,6 +1,6 @@ import React from "react" import { Pressable } from "react-native-gesture-handler" -import Colors from "@/colors" +import Colors from "@/Colors" import { Ionicons } from "@expo/vector-icons" import { useNavigation } from "@react-navigation/native" import { View } from "react-native" diff --git a/src/components/FormPicker.tsx b/src/components/FormPicker.tsx index a2cc16c..46d49a2 100644 --- a/src/components/FormPicker.tsx +++ b/src/components/FormPicker.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { MenuAction } from "@react-native-menu/menu" import { StyleSheet, Text, View } from "react-native" import { Picker } from "./Picker" diff --git a/src/components/MainNote.tsx b/src/components/MainNote.tsx index 4c6fda0..d6e6df4 100644 --- a/src/components/MainNote.tsx +++ b/src/components/MainNote.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { InstrumentString } from "@/instruments" import { useParagraphBuilder } from "@/paragraphs" import { useTranslation } from "@/configHooks" diff --git a/src/components/MovingGrid.tsx b/src/components/MovingGrid.tsx index 6f225e7..dc997b2 100644 --- a/src/components/MovingGrid.tsx +++ b/src/components/MovingGrid.tsx @@ -3,7 +3,7 @@ import { useSharedValue, useDerivedValue, Easing, cancelAnimation } from "react- import { withTiming, withRepeat } from "react-native-reanimated" import { Rect, Line, LinearGradient, Group, vec, Points, Mask } from "@shopify/react-native-skia" import { useWindowDimensions } from "react-native" -import Colors from "@/colors" +import Colors from "@/Colors" import { useConfigStore } from "@/stores/configStore" const GRID_COLOR = Colors.bgInactive diff --git a/src/components/Picker.tsx b/src/components/Picker.tsx index de2f7e9..1a1819b 100644 --- a/src/components/Picker.tsx +++ b/src/components/Picker.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { MenuAction, MenuView } from "@react-native-menu/menu" import { ReactNode } from "react" import { Appearance, Platform } from "react-native" diff --git a/src/components/RequireMicAccess.tsx b/src/components/RequireMicAccess.tsx index 0591e61..247230f 100644 --- a/src/components/RequireMicAccess.tsx +++ b/src/components/RequireMicAccess.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { useTranslation } from "@/configHooks" import React from "react" import { View, Text, StyleSheet, TouchableOpacity, Linking, Platform } from "react-native" diff --git a/src/components/RightButtons.tsx b/src/components/RightButtons.tsx index e4f3d68..64453e7 100644 --- a/src/components/RightButtons.tsx +++ b/src/components/RightButtons.tsx @@ -1,12 +1,12 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { Instrument } from "@/instruments" -import { getTuningFreq, InstrumentType, TuningType, useConfigStore } from "@/stores/configStore" +import { getTuningFreq, InstrumentType, TuningType, StretchTuningType, useConfigStore } from "@/stores/configStore" import { Feather, FontAwesome5, Ionicons } from "@expo/vector-icons" import { Linking, Platform, Pressable, Text, useWindowDimensions, View } from "react-native" import { Picker } from "./Picker" import { MenuAction } from "@react-native-menu/menu" import { useMemo } from "react" -import { useTranslation } from "@/configHooks" +import { useTranslation, useSettingsOptions } from "@/configHooks" export const RightButtons = ({ positionY, @@ -20,8 +20,11 @@ export const RightButtons = ({ const setManual = useConfigStore((state) => state.setManual) const setInstrument = useConfigStore((state) => state.setInstrument) const setTuning = useConfigStore((state) => state.setTuning) + const setStretchTuning = useConfigStore((state) => state.setStretchTuning) const tuning = useConfigStore((state) => state.tuning) + const stretchTuning = useConfigStore((state) => state.stretchTuning) const t = useTranslation() + const options = useSettingsOptions() const btnW = width / 7 const fontHeight = height / 70 const fontSize = fontHeight / 1.3 @@ -85,6 +88,52 @@ export const RightButtons = ({ ] }, [t]) + const stretchTunings: MenuAction[] = useMemo(() => { + const subactions = [ + { + id: "none" as StretchTuningType, + title: t("stretch_none"), + displayInline: true, + }, + { + id: "upright" as StretchTuningType, + title: t("stretch_upright"), + displayInline: true, + }, + { + id: "spinet" as StretchTuningType, + title: t("stretch_spinet"), + displayInline: true, + }, + { + id: "console" as StretchTuningType, + title: t("stretch_console"), + displayInline: true, + }, + { + id: "studio" as StretchTuningType, + title: t("stretch_studio"), + displayInline: true, + }, + { + id: "baby_grand" as StretchTuningType, + title: t("stretch_baby_grand"), + displayInline: true, + }, + ] + // Avoid nested menus in android (collapsed by default) + return Platform.OS === "android" + ? subactions + : [ + { + id: "stretch-tuning-type", + title: t("stretch_tuning"), + displayInline: true, + subactions, + }, + ] + }, [t]) + return ( + {/* Stretch tuning picker - only show for chromatic instrument */} + {instrument.name === "chromatic" && ( + setStretchTuning(value as StretchTuningType)} value={stretchTuning}> + + + {t("stretch_tuning")} + + + {options.getStretchTuningName(stretchTuning)} + + + + )} + { await Linking.openURL(`https://www.instagram.com/tuneo.app/`) diff --git a/src/components/Strings.tsx b/src/components/Strings.tsx index e8c0316..3dc5964 100644 --- a/src/components/Strings.tsx +++ b/src/components/Strings.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { Instrument } from "@/instruments" import { getFreqFromNote } from "@/notes" import { useConfigStore } from "@/stores/configStore" diff --git a/src/components/TuningGauge.tsx b/src/components/TuningGauge.tsx index 7a385e3..b88c383 100644 --- a/src/components/TuningGauge.tsx +++ b/src/components/TuningGauge.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { Circle, Group, Line, Paint } from "@shopify/react-native-skia" import { useEffect } from "react" import { useWindowDimensions } from "react-native" diff --git a/src/components/Waveform.tsx b/src/components/Waveform.tsx index 4d8c0b7..6cc6a33 100644 --- a/src/components/Waveform.tsx +++ b/src/components/Waveform.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { Group, Path, SkPath, usePathInterpolation } from "@shopify/react-native-skia" import { useEffect, useMemo, useState } from "react" import { useWindowDimensions } from "react-native" diff --git a/src/configHooks.ts b/src/configHooks.ts index 21f2a00..501d8aa 100644 --- a/src/configHooks.ts +++ b/src/configHooks.ts @@ -8,6 +8,8 @@ import { InstrumentType, LANGUAGE_IDS, LanguageType, + STRETCH_TUNING_IDS, + StretchTuningType, THEME_IDS, ThemeType, TUNING_IDS, @@ -75,6 +77,23 @@ export const useSettingsOptions = () => { } }, + getStretchTuningName: (stretchTuning: StretchTuningType): string => { + switch (stretchTuning) { + case "none": + return t("stretch_none") + case "upright": + return t("stretch_upright") + case "spinet": + return t("stretch_spinet") + case "console": + return t("stretch_console") + case "studio": + return t("stretch_studio") + case "baby_grand": + return t("stretch_baby_grand") + } + }, + getGraphicModeName: (graphics: GraphicsMode): string => { switch (graphics) { case "high": @@ -96,6 +115,9 @@ export const useSettingsOptions = () => { getTunings: function () { return TUNING_IDS.map((id) => ({ id, title: this.getTuningName(id) } as MenuAction)) }, + getStretchTunings: function () { + return STRETCH_TUNING_IDS.map((id) => ({ id, title: this.getStretchTuningName(id) } as MenuAction)) + }, getGraphics: function () { return GRAPHIC_MODES.map((id) => ({ id, title: this.getGraphicModeName(id) } as MenuAction)) }, diff --git a/src/instruments.ts b/src/instruments.ts index 389d655..2bdeafa 100644 --- a/src/instruments.ts +++ b/src/instruments.ts @@ -1,15 +1,17 @@ import { getFreqFromNote, getNoteFromFreq, Note } from "./notes" -import { InstrumentType, TuningType } from "./stores/configStore" +import { InstrumentType, TuningType, StretchTuningType } from "./stores/configStore" export type InstrumentString = { note: Note; freq: number } export abstract class Instrument { tuning: TuningType + stretchTuning: StretchTuningType abstract readonly name: InstrumentType abstract readonly hasStrings: boolean - constructor(tuning: TuningType) { + constructor(tuning: TuningType, stretchTuning: StretchTuningType = "none") { this.tuning = tuning + this.stretchTuning = stretchTuning } abstract getStrings(): Note[] @@ -42,9 +44,9 @@ export class Guitar extends Instrument { ] stringFreqs: number[] // depends on tuning type - constructor(tuning: TuningType) { - super(tuning) - this.stringFreqs = this.stringNotes.map((note) => getFreqFromNote(note, tuning)) + constructor(tuning: TuningType, stretchTuning: StretchTuningType = "none") { + super(tuning, stretchTuning) + this.stringFreqs = this.stringNotes.map((note) => getFreqFromNote(note, tuning, stretchTuning)) } get name(): InstrumentType { @@ -85,8 +87,8 @@ export class Chromatic extends Instrument { const note = getNoteFromFreq(freq, this.tuning) if (!note) return undefined - // Find frequency of the nearest note - const noteFreq = getFreqFromNote(note, this.tuning) + // Find frequency of the nearest note with stretch tuning applied + const noteFreq = getFreqFromNote(note, this.tuning, this.stretchTuning) return { note, freq: noteFreq } } } diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 11011d3..f973369 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -3,7 +3,7 @@ import type { StaticParamList } from "@react-navigation/native" import { createNativeStackNavigator } from "@react-navigation/native-stack" import { Settings } from "./screens/Settings" import { Tuneo } from "./screens/Tuneo" -import Colors from "@/colors" +import Colors from "@/Colors" import { CloseButton } from "@/components/CloseButton" import { Platform } from "react-native" diff --git a/src/navigation/screens/Settings.tsx b/src/navigation/screens/Settings.tsx index 6b97b7e..93dc420 100644 --- a/src/navigation/screens/Settings.tsx +++ b/src/navigation/screens/Settings.tsx @@ -1,4 +1,4 @@ -import Colors from "@/colors" +import Colors from "@/Colors" import { FormPicker } from "@/components/FormPicker" import { useSettingsOptions, useTranslation } from "@/configHooks" import { GraphicsMode, LanguageType, ThemeType, useConfigStore } from "@/stores/configStore" diff --git a/src/navigation/screens/Tuneo.tsx b/src/navigation/screens/Tuneo.tsx index 58895f8..df2ed15 100644 --- a/src/navigation/screens/Tuneo.tsx +++ b/src/navigation/screens/Tuneo.tsx @@ -5,7 +5,7 @@ import { Canvas } from "@shopify/react-native-skia" import DSPModule from "@/../specs/NativeDSPModule" import MicrophoneStreamModule, { AudioBuffer } from "@/../modules/microphone-stream" import { AudioModule } from "expo-audio" -import Colors from "@/colors" +import Colors from "@/Colors" import { getTestSignal } from "@/test" import MovingGrid from "@/components/MovingGrid" import ConfigButton from "@/components/ConfigButton" @@ -184,11 +184,11 @@ export const Tuneo = () => { const instrument: Instrument = useMemo(() => { switch (config.instrument) { case "guitar": - return new Guitar(config.tuning) + return new Guitar(config.tuning, config.stretchTuning) case "chromatic": - return new Chromatic(config.tuning) + return new Chromatic(config.tuning, config.stretchTuning) } - }, [config.instrument, config.tuning]) + }, [config.instrument, config.tuning, config.stretchTuning]) // Disable manual mode if instrument doesn't support strings useEffect(() => { diff --git a/src/notes.ts b/src/notes.ts index e078332..82c7435 100644 --- a/src/notes.ts +++ b/src/notes.ts @@ -1,4 +1,5 @@ -import { TuningType } from "./configHooks" +import { TuningType, StretchTuningType } from "./stores/configStore" +import { applyStretchTuning } from "./stretchTuning" const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] as const // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -27,7 +28,7 @@ export function getNoteFromFreq(frequency: number, tuning: TuningType): Note | u // Calculate the number of semitones from reference A4 const a4_frequency = getReferenceFrequency(tuning) - const semitonesFromA4 = 12 * Math.log2(frequency / a4_frequency) + const semitonesFromA4 = 12 * (Math.log(frequency / a4_frequency) / Math.LN2) // Octaves start in C, calculate semitones from C4 const semitonesFromC4 = Math.round(semitonesFromA4 + 9) @@ -42,9 +43,11 @@ export function getNoteFromFreq(frequency: number, tuning: TuningType): Note | u /** * Calculates the frequency of a note given its name and octave. * @param note The name and octave of the note. + * @param tuning The tuning type + * @param stretchTuning The stretch tuning type (optional) * @returns The frequency of the note in Hz. */ -export function getFreqFromNote(note: Note | undefined, tuning: TuningType): number { +export function getFreqFromNote(note: Note | undefined, tuning: TuningType, stretchTuning?: StretchTuningType): number { if (!note) return 0 const a4_frequency = getReferenceFrequency(tuning) @@ -53,8 +56,15 @@ export function getFreqFromNote(note: Note | undefined, tuning: TuningType): num const noteDistance = NOTE_NAMES.indexOf(note.name) - NOTE_NAMES.indexOf("A") const semitonesFromA4 = (note.octave - 4) * 12 + noteDistance - // freq = ref^(semitones / 12) - return a4_frequency * Math.pow(2, semitonesFromA4 / 12) + // Calculate base frequency in equal temperament + const baseFreq = a4_frequency * Math.pow(2, semitonesFromA4 / 12) + + // Apply stretch tuning if specified + if (stretchTuning) { + return applyStretchTuning(baseFreq, stretchTuning) + } + + return baseFreq } /** diff --git a/src/stores/configStore.ts b/src/stores/configStore.ts index d3742e1..457d72c 100644 --- a/src/stores/configStore.ts +++ b/src/stores/configStore.ts @@ -8,12 +8,14 @@ export const INSTRUMENT_IDS = ["guitar", "chromatic"] as const export const THEME_IDS = ["dark"] as const export const LANGUAGE_IDS = ["en", "es"] as const export const TUNING_IDS = ["ref_440", "ref_432", "ref_444"] as const +export const STRETCH_TUNING_IDS = ["none", "upright", "spinet", "console", "studio", "baby_grand"] as const export const GRAPHIC_MODES = ["low", "high"] as const export type InstrumentType = (typeof INSTRUMENT_IDS)[number] export type ThemeType = (typeof THEME_IDS)[number] export type LanguageType = (typeof LANGUAGE_IDS)[number] export type TuningType = (typeof TUNING_IDS)[number] +export type StretchTuningType = (typeof STRETCH_TUNING_IDS)[number] export type GraphicsMode = (typeof GRAPHIC_MODES)[number] export interface ConfigState { @@ -21,12 +23,14 @@ export interface ConfigState { theme: ThemeType language: LanguageType tuning: TuningType + stretchTuning: StretchTuningType graphics: GraphicsMode manual: boolean setLanguage: (language: LanguageType) => void setInstrument: (instrument: InstrumentType) => void setTheme: (theme: ThemeType) => void setTuning: (tuning: TuningType) => void + setStretchTuning: (stretchTuning: StretchTuningType) => void setGraphics: (grahpics: GraphicsMode) => void setManual: (manual: boolean) => void } @@ -46,6 +50,7 @@ export const useConfigStore = create()( // Not persistent values below (see merge function) instrument: "guitar", tuning: "ref_440", + stretchTuning: "none", manual: false, setLanguage: (language: LanguageType) => set({ language }), @@ -53,6 +58,7 @@ export const useConfigStore = create()( setGraphics: (graphics: GraphicsMode) => set({ graphics }), setInstrument: (instrument: InstrumentType) => set({ instrument }), setTuning: (tuning: TuningType) => set({ tuning }), + setStretchTuning: (stretchTuning: StretchTuningType) => set({ stretchTuning }), setManual: (manual) => set({ manual }), }), { diff --git a/src/stretchTuning.ts b/src/stretchTuning.ts new file mode 100644 index 0000000..8921dcf --- /dev/null +++ b/src/stretchTuning.ts @@ -0,0 +1,96 @@ +import { StretchTuningType } from "./stores/configStore" + +/** + * Stretch tuning profiles for different home piano types + * + * Stretch tuning compensates for the inharmonicity of piano strings, making + * octaves sound more in tune. Different piano sizes and designs require + * different amounts of stretch. + * + * These values represent cents deviation from equal temperament + * Positive values = sharp, negative values = flat + */ +export interface StretchProfile { + name: string + description: string + // Cents deviation from equal temperament for each octave + // Index 0 = C0, Index 1 = C1, etc. + octaveStretch: number[] + // Additional stretch within octave (cents) + // Applied to higher notes within each octave + withinOctaveStretch: number +} + +export const STRETCH_PROFILES: Record = { + spinet: { + name: "Spinet", + description: "Small upright piano (36-40\" height) - compact home pianos", + // Conservative stretch: bass minimal, treble moderate + octaveStretch: [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4], + withinOctaveStretch: 0.2, // Minimal stretch within octave + }, + console: { + name: "Console", + description: "Medium upright piano (40-44\" height) - traditional home pianos", + // Moderate stretch: bass light, treble moderate + octaveStretch: [0, 1, 2, 3, 4, 5, 6, 7, 8], + withinOctaveStretch: 0.4, // Light stretch within octave + }, + upright: { + name: "Upright", + description: "Standard upright piano (42-48\" height) - most common home piano", + // Standard stretch: bass moderate, treble pronounced + octaveStretch: [0, 1.5, 3, 4.5, 6, 7.5, 9, 10.5, 12], + withinOctaveStretch: 0.6, // Moderate stretch within octave + }, + studio: { + name: "Studio", + description: "Professional upright piano (45-52\" height) - high-quality home/studio pianos", + // Professional stretch: bass moderate, treble aggressive + octaveStretch: [0, 2, 4, 6, 8, 10, 12, 14, 16], + withinOctaveStretch: 0.8, // More stretch within octave + }, + baby_grand: { + name: "Baby Grand", + description: "Baby grand piano (5-6' length) - compact grand pianos for home use", + // Grand piano stretch: bass moderate, treble very pronounced + octaveStretch: [0, 2.5, 5, 7.5, 10, 12.5, 15, 17.5, 20], + withinOctaveStretch: 1.0, // More stretch within octave for grand piano sound + }, +} + +/** + * Apply stretch tuning to a frequency + * @param baseFreq The frequency in equal temperament + * @param stretchTuning The stretch tuning type + * @returns The stretched frequency + */ +export function applyStretchTuning(baseFreq: number, stretchTuning: StretchTuningType): number { + if (stretchTuning === "none") { + return baseFreq // No stretch + } + + const profile = STRETCH_PROFILES[stretchTuning] + if (!profile) { + return baseFreq // Fallback to equal temperament + } + + // Calculate the octave and note within octave + const a4Freq = 440 + const semitonesFromA4 = 12 * (Math.log(baseFreq / a4Freq) / Math.LN2) + const octaveFromA4 = Math.floor(semitonesFromA4 / 12) + const octaveIndex = 4 + octaveFromA4 // A4 is in octave 4 + + // Get octave stretch + const octaveStretch = profile.octaveStretch[Math.max(0, Math.min(octaveIndex, profile.octaveStretch.length - 1))] + + // Calculate within-octave stretch based on note position + const semitonesInOctave = ((semitonesFromA4 % 12) + 12) % 12 + const withinOctaveStretch = (semitonesInOctave / 12) * profile.withinOctaveStretch + + // Total stretch in cents + const totalStretchCents = octaveStretch + withinOctaveStretch + + // Apply stretch: freq * 2^(stretch_cents / 1200) + return baseFreq * Math.pow(2, totalStretchCents / 1200) +} \ No newline at end of file diff --git a/src/translations.ts b/src/translations.ts index e520ff6..6eb3100 100644 --- a/src/translations.ts +++ b/src/translations.ts @@ -5,6 +5,7 @@ export interface Translation { mode: string gtr_string: string reference: string + stretch_tuning: string feedback: string more_settings: string no_tone: string @@ -17,6 +18,12 @@ export interface Translation { tuning_440: string tuning_432: string tuning_444: string + stretch_none: string + stretch_upright: string + stretch_spinet: string + stretch_console: string + stretch_studio: string + stretch_baby_grand: string graphics: string graphics_high: string graphics_low: string @@ -31,6 +38,7 @@ export const en: Translation = { mode: "Mode", gtr_string: "STRING", reference: "REFERENCE", + stretch_tuning: "STRETCH", feedback: "FOLLOW", more_settings: "Settings...", no_tone: "No tone", @@ -43,6 +51,12 @@ export const en: Translation = { tuning_440: "440Hz (standard)", tuning_432: "432Hz (Verdi)", tuning_444: "444Hz (high pitch)", + stretch_none: "None", + stretch_upright: "Upright", + stretch_spinet: "Spinet", + stretch_console: "Console", + stretch_studio: "Studio", + stretch_baby_grand: "Baby Grand", graphics: "Graphics", graphics_high: "Better quality", graphics_low: "Better performance", @@ -57,6 +71,7 @@ export const es: Translation = { mode: "Modo", gtr_string: "CUERDA", reference: "REFERENCIA", + stretch_tuning: "ESTIRADO", feedback: "SEGUIR", more_settings: "Configuración...", no_tone: "Sin tono", @@ -69,6 +84,12 @@ export const es: Translation = { tuning_440: "440Hz (estándar)", tuning_432: "432Hz (Verdi)", tuning_444: "444Hz (tono alto)", + stretch_none: "Ninguno", + stretch_upright: "Vertical", + stretch_spinet: "Espineta", + stretch_console: "Consola", + stretch_studio: "Estudio", + stretch_baby_grand: "Baby Grand", graphics: "Gráficos", graphics_high: "Mejor calidad", graphics_low: "Mejor rendimiento",