434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
// Copyright (c) 2022 Robin Davies
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
// this software and associated documentation files (the "Software"), to deal in
|
|
// the Software without restriction, including without limitation the rights to
|
|
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
// the Software, and to permit persons to whom the Software is furnished to do so,
|
|
// subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in all
|
|
// copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
import React, {Component} from 'react';
|
|
import { Theme } from '@mui/material/styles';
|
|
import { WithStyles } from '@mui/styles';
|
|
import createStyles from '@mui/styles/createStyles';
|
|
import withStyles from '@mui/styles/withStyles';
|
|
import { MonitorPortHandle, PiPedalModel, State, PiPedalModelFactory } from "./PiPedalModel";
|
|
import SvgPathBuilder from './SvgPathBuilder'
|
|
import {isDarkMode} from './DarkMode';
|
|
//const char* model[] = {"12-TET","19-TET","24-TET", "31-TET", "53-TET"};
|
|
// set_adjustment(ui->widget[2]->adj,440.0, 440.0, 427.0, 453.0, 0.1, CL_CONTINUOS);
|
|
|
|
const DEFAULT_FREQUENCY_PORT_NAME = "FREQ";
|
|
|
|
const DIAL_WIDTH= 220;
|
|
const DIAL_HEIGHT = 100;
|
|
const NEEDLE_CY = 320;
|
|
const CENTS_PER_MARK = 5;
|
|
const NEEDLE_OUTER_RADIUS = 0.98;
|
|
const TICK_OUTER_RADIUS = 0.96;
|
|
const TICK_INNER_RADIUS = 0.85;
|
|
const TICK_INNER_ZERO_RADIUS = 0.82;
|
|
const DIAL_ANGLE_RADIANS = 20*Math.PI/180;
|
|
|
|
|
|
const styles = (theme: Theme) =>
|
|
createStyles({
|
|
icon: {
|
|
width: "24px",
|
|
height: "24px",
|
|
margin: "0px",
|
|
opacity: "0.6"
|
|
}
|
|
});
|
|
|
|
interface PitchInfo {
|
|
valid: boolean;
|
|
name: string;
|
|
fractionText: string;
|
|
fraction: number;
|
|
|
|
semitoneCents: number
|
|
}
|
|
|
|
const FLAT = "\u{266d}";
|
|
const SHARP = "#";
|
|
const DOUBLE_FLAT = "\uD834\uDD2B";
|
|
const DOUBLE_SHARP = "\uD834\uDD2A";
|
|
const HALF_FLAT = "\uD834\uDD33";
|
|
const HALF_SHARP = "\uD834\uDD32";
|
|
|
|
|
|
const NOTES_12TET: string[] = [
|
|
"C", "C" + SHARP, "D", "E"+FLAT,"E", "F", "F" + SHARP, "G", "A"+FLAT,"A","B"+FLAT,"B"
|
|
];
|
|
const NOTES_19TET: string[] = [
|
|
"C", "C" + SHARP, "D"+FLAT, "D", "D" + SHARP,"E"+FLAT,"E", "E" + SHARP, "F", "F" + SHARP, "G"+FLAT,"G","G" + SHARP,"A"+FLAT,"A","A" + SHARP,"B"+FLAT,"B","C"+FLAT
|
|
];
|
|
const NOTES_24TET: string[] = [
|
|
"C","C"+HALF_SHARP, "C" + SHARP,"D"+HALF_FLAT, "D", "D"+HALF_SHARP, "E"+FLAT, "E"+HALF_FLAT, "E","E"+HALF_SHARP,"F", "F"+HALF_SHARP, "F" + SHARP, "G"+HALF_FLAT,"G","G"+HALF_SHARP, "A"+FLAT,"A"+HALF_FLAT,"A","A"+HALF_SHARP,"B"+FLAT,"B"+HALF_FLAT,"B","C"+HALF_FLAT
|
|
];
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const NOTES_31TET: string[] = [
|
|
"C", "D"+DOUBLE_FLAT, "C" + SHARP,"Db","C"+DOUBLE_SHARP,"D","E"+DOUBLE_FLAT,"D" + SHARP,"Eb","D"+DOUBLE_SHARP,"E",'Fb","E" + SHARP,"F","G"+DOUBLE_FLAT,"F" + SHARP,"Gb""Fx","G","A"+DOUBLE_FLAT,"G"+DOUBLE_SHARP,"A"'
|
|
];
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const NOTES_53TET = ["la","laa","lo","law","ta","teh","te","tu","tuh","ti","tih","to","taw","da","do","di","daw","ro","rih","ra","ru","ruh","reh","re ","ri","raw","ma","meh","me","mu","muh","mi","maa","mo","maw","fe","fa","fih","fu","fuh","fi","se","suh","su","sih","sol","si","saw","lo","leh","le","lu","luh"];
|
|
|
|
interface GxTunerControlProps extends WithStyles<typeof styles> {
|
|
theme: Theme;
|
|
instanceId: number;
|
|
valueIsMidi: boolean;
|
|
symbolName?: string;
|
|
}
|
|
type GxTunerControlState = {
|
|
pitchInfo: PitchInfo
|
|
tet: number;
|
|
refFrequency: number;
|
|
animationTime: number;
|
|
};
|
|
|
|
const GxTunerControl =
|
|
withStyles(styles, { withTheme: true })(
|
|
class extends Component<GxTunerControlProps, GxTunerControlState>
|
|
{
|
|
model: PiPedalModel;
|
|
|
|
|
|
refRoot: React.RefObject<HTMLDivElement>;
|
|
|
|
constructor(props: GxTunerControlProps) {
|
|
super(props);
|
|
this.model = PiPedalModelFactory.getInstance();
|
|
|
|
this.refRoot = React.createRef();
|
|
|
|
this.state = {
|
|
pitchInfo: {
|
|
valid: false,
|
|
name: "",
|
|
fractionText: "",
|
|
fraction: 0,
|
|
semitoneCents: 100},
|
|
tet: 12,
|
|
animationTime: 0,
|
|
refFrequency: 440
|
|
|
|
};
|
|
this.onFrequencyUpdated = this.onFrequencyUpdated.bind(this);
|
|
this.onStateChanged = this.onStateChanged.bind(this);
|
|
}
|
|
|
|
frequencyPortName() : string {
|
|
if (this.props.symbolName) { return this.props.symbolName;}
|
|
return DEFAULT_FREQUENCY_PORT_NAME;
|
|
}
|
|
|
|
noteToPitchInfo(value: number) : PitchInfo
|
|
{
|
|
let aOffset = 69;
|
|
|
|
let tet = this.state.tet;
|
|
let refFrequency = this.state.refFrequency;
|
|
let names: string[];
|
|
let semitoneCents = 100;
|
|
let valid = false;
|
|
|
|
let hz = -1;
|
|
let midiNote = -1;
|
|
|
|
|
|
switch (tet)
|
|
{
|
|
case 12:
|
|
default:
|
|
aOffset = 69;
|
|
names = NOTES_12TET;
|
|
semitoneCents = 100;
|
|
break;
|
|
case 19:
|
|
aOffset = 4*19+13;
|
|
names = NOTES_19TET;
|
|
semitoneCents = 100*12.0/19;
|
|
break;
|
|
case 24:
|
|
aOffset = 4*24+18;
|
|
semitoneCents = 50;
|
|
names = NOTES_24TET;
|
|
break;
|
|
}
|
|
let name = "";
|
|
let fractionText = "";
|
|
let fraction = 0;
|
|
|
|
if (this.props.valueIsMidi)
|
|
{
|
|
midiNote = value;
|
|
} else {
|
|
if (value < 65)
|
|
{
|
|
hz = -1;
|
|
midiNote = -1;
|
|
} else {
|
|
midiNote = Math.log2(hz/refFrequency)*tet + aOffset;
|
|
}
|
|
}
|
|
|
|
|
|
if (midiNote > 0)
|
|
{
|
|
|
|
let note = midiNote;
|
|
let noteNumber = Math.round(note);
|
|
|
|
let octave = Math.floor((noteNumber)/tet);
|
|
|
|
let nameIndex = noteNumber -octave* tet;
|
|
if (nameIndex < 0) nameIndex += tet;
|
|
name = names[ nameIndex ] + (octave-2);
|
|
valid = true;
|
|
fraction = note-noteNumber;
|
|
if (fraction >= 0) {
|
|
fractionText = "+" + fraction.toFixed(2).substr(1);
|
|
} else {
|
|
fractionText = "\u2212" + fraction.toFixed(2).substr(2);
|
|
|
|
}
|
|
}
|
|
return {
|
|
valid: valid,
|
|
name: name,
|
|
fraction: fraction,
|
|
fractionText: fractionText,
|
|
semitoneCents: semitoneCents
|
|
};
|
|
}
|
|
|
|
lastValue: number = -1;
|
|
onFrequencyUpdated(value: number): void
|
|
{
|
|
// suppress repeated zeros.
|
|
if (value === this.lastValue && value <= 0 && this.animationIdle) return;
|
|
|
|
this.lastValue = value;
|
|
|
|
let pitchInfo = this.noteToPitchInfo(value);
|
|
this.setState({
|
|
pitchInfo: pitchInfo
|
|
});
|
|
if (value <= 0)
|
|
{
|
|
this.startAnimationTimer();
|
|
} else {
|
|
this.cancelAnimationTimer();
|
|
}
|
|
}
|
|
|
|
animationTimer?: NodeJS.Timeout = undefined;
|
|
|
|
cancelAnimationTimer(): void {
|
|
if (this.animationTimer)
|
|
{
|
|
clearTimeout(this.animationTimer);
|
|
this.animationTimer = undefined;
|
|
}
|
|
}
|
|
|
|
|
|
startAnimationTimer(): void {
|
|
if (!this.animationTimer)
|
|
{
|
|
this.animationTimer = setInterval(
|
|
() => {
|
|
this.setState({
|
|
animationTime: new Date().getTime()
|
|
});
|
|
},
|
|
1000/30
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
subscribedInstanceId: number = -1;
|
|
|
|
monitorHandle?: MonitorPortHandle;
|
|
|
|
addSubscription() {
|
|
this.subscribedInstanceId = this.props.instanceId;
|
|
this.monitorHandle = this.model.monitorPort(this.props.instanceId,this.frequencyPortName(),1.0/30,this.onFrequencyUpdated);
|
|
}
|
|
removeSubscription() {
|
|
this.subscribedInstanceId = -1;
|
|
if (this.monitorHandle) {
|
|
this.model.unmonitorPort(this.monitorHandle);
|
|
this.monitorHandle = undefined;
|
|
}
|
|
}
|
|
updateSubscription() {
|
|
if (this.subscribedInstanceId !== this.props.instanceId)
|
|
{
|
|
this.removeSubscription();
|
|
this.addSubscription();
|
|
|
|
}
|
|
}
|
|
|
|
onStateChanged(state: State)
|
|
{
|
|
// initial or reconnect
|
|
if (state === State.Ready)
|
|
{
|
|
this.removeSubscription();
|
|
this.updateSubscription();
|
|
}
|
|
}
|
|
componentDidMount()
|
|
{
|
|
this.model.state.addOnChangedHandler(this.onStateChanged);
|
|
this.addSubscription();
|
|
}
|
|
componentDidUpdate() {
|
|
this.updateSubscription();
|
|
}
|
|
componentWillUnmount()
|
|
{
|
|
this.model.state.removeOnChangedHandler(this.onStateChanged);
|
|
this.removeSubscription();
|
|
this.cancelAnimationTimer();
|
|
}
|
|
|
|
|
|
|
|
makeDialTick(pitchInfo: PitchInfo, cents: number): React.ReactNode {
|
|
let isZeroTick = Math.abs(cents) < 0.001;
|
|
let r0 = TICK_OUTER_RADIUS;
|
|
let r1 = (isZeroTick? TICK_INNER_ZERO_RADIUS: TICK_INNER_RADIUS);
|
|
let width = isZeroTick? 3: 2;
|
|
return this.makeTick(pitchInfo,cents,r0,r1,"#666", width);
|
|
}
|
|
|
|
makeTick(pitchInfo: PitchInfo, cents: number, r0: number, r1: number, stroke: string,width: number): React.ReactNode
|
|
{
|
|
let range = pitchInfo.semitoneCents;
|
|
if (range > 50) {
|
|
range = 50;
|
|
}
|
|
r0 *= NEEDLE_CY;
|
|
r1 *= NEEDLE_CY;
|
|
let angle = DIAL_ANGLE_RADIANS * (cents*2/(range));
|
|
|
|
let sin_ = Math.sin(angle);
|
|
let cos_ = -Math.cos(angle);
|
|
let cx = DIAL_WIDTH/2;
|
|
let cy = NEEDLE_CY;
|
|
let path = new SvgPathBuilder().moveTo(r0*sin_+cx,r0*cos_+cy).lineTo(r1*sin_+cx,r1*cos_+cy).toString();
|
|
|
|
return (<path d={path} stroke={stroke} strokeWidth={width+""} />);
|
|
}
|
|
|
|
lastValidTime: number = 0;
|
|
lastValidCents: number = -100;
|
|
animationIdle: boolean = true;
|
|
|
|
makeNeedle(pitchInfo: PitchInfo)
|
|
{
|
|
let maxCents = pitchInfo.semitoneCents/2;
|
|
if (maxCents > 25) {
|
|
maxCents = 25;
|
|
}
|
|
let cents: number;
|
|
if (!pitchInfo.valid)
|
|
{
|
|
const NEEDLE_DECAY_RATE_PER_MS = -50*7/1000;
|
|
|
|
// animate to zero position.
|
|
let time = new Date().getTime();
|
|
let dt = time-this.lastValidTime;
|
|
|
|
cents = this.lastValidCents+(dt*NEEDLE_DECAY_RATE_PER_MS);
|
|
if (cents <= -maxCents)
|
|
{
|
|
cents = -maxCents;
|
|
this.animationIdle = true;
|
|
this.cancelAnimationTimer();
|
|
} else {
|
|
this.animationIdle = false;
|
|
}
|
|
} else {
|
|
cents = pitchInfo.semitoneCents*pitchInfo.fraction;
|
|
if (cents > maxCents) {
|
|
cents = maxCents;
|
|
}
|
|
if (cents < -maxCents)
|
|
{
|
|
cents = -maxCents;
|
|
}
|
|
this.lastValidCents = cents;
|
|
this.lastValidTime = new Date().getTime();
|
|
}
|
|
return this.makeTick(pitchInfo,cents,NEEDLE_OUTER_RADIUS,0.1,"#800",3);
|
|
}
|
|
|
|
renderDial(pitchInfo: PitchInfo)
|
|
{
|
|
|
|
let bounds = "0,0," + DIAL_WIDTH+ "," +DIAL_HEIGHT;
|
|
|
|
let content: React.ReactNode[] = [];
|
|
|
|
let maxCents = Math.floor(pitchInfo.semitoneCents/2 / CENTS_PER_MARK)*CENTS_PER_MARK;
|
|
|
|
for (let cents = -maxCents; cents <= maxCents+ 0.01; cents += CENTS_PER_MARK) {
|
|
content.push(this.makeDialTick(pitchInfo,cents));
|
|
}
|
|
|
|
content.push(
|
|
this.makeNeedle(pitchInfo)
|
|
);
|
|
|
|
|
|
return (
|
|
<svg viewBox={bounds} width={DIAL_WIDTH} height={DIAL_HEIGHT} style={{position: "absolute"}} >
|
|
{ content }
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
render() {
|
|
let textColor = isDarkMode() ? "#999": "#444";
|
|
return (<div ref={this.refRoot} style={{width: DIAL_WIDTH, height: DIAL_HEIGHT, fontSize: "2em", fontWeight: 700, position: "relative",
|
|
boxShadow: isDarkMode() ?
|
|
"5px 5px 6px rgba(0,0,0,0.8) inset":
|
|
"1px 5px 6px #888 inset",
|
|
background: isDarkMode()? "rgba(255,255,255,0.07)" : "",
|
|
|
|
fontFamily: "arial,roboto,helvetica,sans"}}>
|
|
<div style={{position: "absolute", left: 0, bottom: 5, width: "50%",textAlign: "right"}}>
|
|
<span style={{ marginRight: 20, color: textColor, textAlign: "right" }}>{this.state.pitchInfo.name}</span>
|
|
</div>
|
|
<div style={{position: "absolute", right: 0, bottom: 5, width: "50%",textAlign: "left"}}>
|
|
<span style={{ marginLeft: 20, color: textColor, textAlign: "left" }}>{this.state.pitchInfo.fractionText}</span>
|
|
</div>
|
|
|
|
{ this.renderDial(this.state.pitchInfo) }
|
|
</div>);
|
|
}
|
|
}
|
|
);
|
|
|
|
|
|
|
|
|
|
export default GxTunerControl; |